bastd.ui.gather.privatetab
Defines the Private tab in the gather UI.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines the Private tab in the gather UI.""" 4 5from __future__ import annotations 6 7import os 8import copy 9import time 10from enum import Enum 11from dataclasses import dataclass 12from typing import TYPE_CHECKING, cast 13 14from efro.dataclassio import dataclass_from_dict, dataclass_to_dict 15from bacommon.net import ( 16 PrivateHostingState, 17 PrivateHostingConfig, 18 PrivatePartyConnectResult, 19) 20import ba 21import ba.internal 22from bastd.ui.gather import GatherTab 23from bastd.ui import getcurrency 24 25if TYPE_CHECKING: 26 from typing import Any 27 from bastd.ui.gather import GatherWindow 28 29# Print a bit of info about queries, etc. 30DEBUG_SERVER_COMMUNICATION = os.environ.get('BA_DEBUG_PPTABCOM') == '1' 31 32 33class SubTabType(Enum): 34 """Available sub-tabs.""" 35 36 JOIN = 'join' 37 HOST = 'host' 38 39 40@dataclass 41class State: 42 """Our core state that persists while the app is running.""" 43 44 sub_tab: SubTabType = SubTabType.JOIN 45 46 47class PrivateGatherTab(GatherTab): 48 """The private tab in the gather UI""" 49 50 def __init__(self, window: GatherWindow) -> None: 51 super().__init__(window) 52 self._container: ba.Widget | None = None 53 self._state: State = State() 54 self._hostingstate = PrivateHostingState() 55 self._join_sub_tab_text: ba.Widget | None = None 56 self._host_sub_tab_text: ba.Widget | None = None 57 self._update_timer: ba.Timer | None = None 58 self._join_party_code_text: ba.Widget | None = None 59 self._c_width: float = 0.0 60 self._c_height: float = 0.0 61 self._last_hosting_state_query_time: float | None = None 62 self._waiting_for_initial_state = True 63 self._waiting_for_start_stop_response = True 64 self._host_playlist_button: ba.Widget | None = None 65 self._host_copy_button: ba.Widget | None = None 66 self._host_connect_button: ba.Widget | None = None 67 self._host_start_stop_button: ba.Widget | None = None 68 self._get_tickets_button: ba.Widget | None = None 69 self._ticket_count_text: ba.Widget | None = None 70 self._showing_not_signed_in_screen = False 71 self._create_time = time.time() 72 self._last_action_send_time: float | None = None 73 self._connect_press_time: float | None = None 74 try: 75 self._hostingconfig = self._build_hosting_config() 76 except Exception: 77 ba.print_exception('Error building hosting config') 78 self._hostingconfig = PrivateHostingConfig() 79 80 def on_activate( 81 self, 82 parent_widget: ba.Widget, 83 tab_button: ba.Widget, 84 region_width: float, 85 region_height: float, 86 region_left: float, 87 region_bottom: float, 88 ) -> ba.Widget: 89 self._c_width = region_width 90 self._c_height = region_height - 20 91 self._container = ba.containerwidget( 92 parent=parent_widget, 93 position=( 94 region_left, 95 region_bottom + (region_height - self._c_height) * 0.5, 96 ), 97 size=(self._c_width, self._c_height), 98 background=False, 99 selection_loops_to_parent=True, 100 ) 101 v = self._c_height - 30.0 102 self._join_sub_tab_text = ba.textwidget( 103 parent=self._container, 104 position=(self._c_width * 0.5 - 245, v - 13), 105 color=(0.6, 1.0, 0.6), 106 scale=1.3, 107 size=(200, 30), 108 maxwidth=250, 109 h_align='left', 110 v_align='center', 111 click_activate=True, 112 selectable=True, 113 autoselect=True, 114 on_activate_call=lambda: self._set_sub_tab( 115 SubTabType.JOIN, 116 playsound=True, 117 ), 118 text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'), 119 ) 120 self._host_sub_tab_text = ba.textwidget( 121 parent=self._container, 122 position=(self._c_width * 0.5 + 45, v - 13), 123 color=(0.6, 1.0, 0.6), 124 scale=1.3, 125 size=(200, 30), 126 maxwidth=250, 127 h_align='left', 128 v_align='center', 129 click_activate=True, 130 selectable=True, 131 autoselect=True, 132 on_activate_call=lambda: self._set_sub_tab( 133 SubTabType.HOST, 134 playsound=True, 135 ), 136 text=ba.Lstr(resource='gatherWindow.privatePartyHostText'), 137 ) 138 ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 139 ba.widget( 140 edit=self._host_sub_tab_text, 141 left_widget=self._join_sub_tab_text, 142 up_widget=tab_button, 143 ) 144 ba.widget( 145 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 146 ) 147 148 self._update_timer = ba.Timer( 149 1.0, 150 ba.WeakCall(self._update), 151 repeat=True, 152 timetype=ba.TimeType.REAL, 153 ) 154 155 # Prevent taking any action until we've updated our state. 156 self._waiting_for_initial_state = True 157 158 # This will get a state query sent out immediately. 159 self._last_action_send_time = None # Ensure we don't ignore response. 160 self._last_hosting_state_query_time = None 161 self._update() 162 163 self._set_sub_tab(self._state.sub_tab) 164 165 return self._container 166 167 def _build_hosting_config(self) -> PrivateHostingConfig: 168 # pylint: disable=too-many-branches 169 from bastd.ui.playlist import PlaylistTypeVars 170 from ba.internal import filter_playlist 171 172 hcfg = PrivateHostingConfig() 173 cfg = ba.app.config 174 sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa') 175 if not isinstance(sessiontypestr, str): 176 raise RuntimeError(f'Invalid sessiontype {sessiontypestr}') 177 hcfg.session_type = sessiontypestr 178 179 sessiontype: type[ba.Session] 180 if hcfg.session_type == 'ffa': 181 sessiontype = ba.FreeForAllSession 182 elif hcfg.session_type == 'teams': 183 sessiontype = ba.DualTeamSession 184 else: 185 raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}') 186 pvars = PlaylistTypeVars(sessiontype) 187 188 playlist_name = ba.app.config.get( 189 f'{pvars.config_name} Playlist Selection' 190 ) 191 if not isinstance(playlist_name, str): 192 playlist_name = '__default__' 193 hcfg.playlist_name = ( 194 pvars.default_list_name.evaluate() 195 if playlist_name == '__default__' 196 else playlist_name 197 ) 198 199 playlist: list[dict[str, Any]] | None = None 200 if playlist_name != '__default__': 201 playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get( 202 playlist_name 203 ) 204 if playlist is None: 205 playlist = pvars.get_default_list_call() 206 207 hcfg.playlist = filter_playlist( 208 playlist, sessiontype, name=playlist_name 209 ) 210 211 randomize = cfg.get(f'{pvars.config_name} Playlist Randomize') 212 if not isinstance(randomize, bool): 213 randomize = False 214 hcfg.randomize = randomize 215 216 tutorial = cfg.get('Show Tutorial') 217 if not isinstance(tutorial, bool): 218 tutorial = True 219 hcfg.tutorial = tutorial 220 221 if hcfg.session_type == 'teams': 222 ctn: list[str] | None = cfg.get('Custom Team Names') 223 if ctn is not None: 224 if ( 225 isinstance(ctn, (list, tuple)) 226 and len(ctn) == 2 227 and all(isinstance(x, str) for x in ctn) 228 ): 229 hcfg.custom_team_names = (ctn[0], ctn[1]) 230 else: 231 print(f'Found invalid custom-team-names data: {ctn}') 232 233 ctc: list[list[float]] | None = cfg.get('Custom Team Colors') 234 if ctc is not None: 235 if ( 236 isinstance(ctc, (list, tuple)) 237 and len(ctc) == 2 238 and all(isinstance(x, (list, tuple)) for x in ctc) 239 and all(len(x) == 3 for x in ctc) 240 ): 241 hcfg.custom_team_colors = ( 242 (ctc[0][0], ctc[0][1], ctc[0][2]), 243 (ctc[1][0], ctc[1][1], ctc[1][2]), 244 ) 245 else: 246 print(f'Found invalid custom-team-colors data: {ctc}') 247 248 return hcfg 249 250 def on_deactivate(self) -> None: 251 self._update_timer = None 252 253 def _update_currency_ui(self) -> None: 254 # Keep currency count up to date if applicable. 255 try: 256 t_str = str(ba.internal.get_v1_account_ticket_count()) 257 except Exception: 258 t_str = '?' 259 if self._get_tickets_button: 260 ba.buttonwidget( 261 edit=self._get_tickets_button, 262 label=ba.charstr(ba.SpecialChar.TICKET) + t_str, 263 ) 264 if self._ticket_count_text: 265 ba.textwidget( 266 edit=self._ticket_count_text, 267 text=ba.charstr(ba.SpecialChar.TICKET) + t_str, 268 ) 269 270 def _update(self) -> None: 271 """Periodic updating.""" 272 273 now = ba.time(ba.TimeType.REAL) 274 275 self._update_currency_ui() 276 277 if self._state.sub_tab is SubTabType.HOST: 278 279 # If we're not signed in, just refresh to show that. 280 if ( 281 ba.internal.get_v1_account_state() != 'signed_in' 282 and self._showing_not_signed_in_screen 283 ): 284 self._refresh_sub_tab() 285 else: 286 287 # Query an updated state periodically. 288 if ( 289 self._last_hosting_state_query_time is None 290 or now - self._last_hosting_state_query_time > 15.0 291 ): 292 self._debug_server_comm('querying private party state') 293 if ba.internal.get_v1_account_state() == 'signed_in': 294 ba.internal.add_transaction( 295 { 296 'type': 'PRIVATE_PARTY_QUERY', 297 'expire_time': time.time() + 20, 298 }, 299 callback=ba.WeakCall( 300 self._hosting_state_idle_response 301 ), 302 ) 303 ba.internal.run_transactions() 304 else: 305 self._hosting_state_idle_response(None) 306 self._last_hosting_state_query_time = now 307 308 def _hosting_state_idle_response( 309 self, result: dict[str, Any] | None 310 ) -> None: 311 312 # This simply passes through to our standard response handler. 313 # The one exception is if we've recently sent an action to the 314 # server (start/stop hosting/etc.) In that case we want to ignore 315 # idle background updates and wait for the response to our action. 316 # (this keeps the button showing 'one moment...' until the change 317 # takes effect, etc.) 318 if ( 319 self._last_action_send_time is not None 320 and time.time() - self._last_action_send_time < 5.0 321 ): 322 self._debug_server_comm( 323 'ignoring private party state response due to recent action' 324 ) 325 return 326 self._hosting_state_response(result) 327 328 def _hosting_state_response(self, result: dict[str, Any] | None) -> None: 329 330 # Its possible for this to come back to us after our UI is dead; 331 # ignore in that case. 332 if not self._container: 333 return 334 335 state: PrivateHostingState | None = None 336 if result is not None: 337 self._debug_server_comm('got private party state response') 338 try: 339 state = dataclass_from_dict( 340 PrivateHostingState, result, discard_unknown_attrs=True 341 ) 342 except Exception: 343 ba.print_exception('Got invalid PrivateHostingState data') 344 else: 345 self._debug_server_comm('private party state response errored') 346 347 # Hmm I guess let's just ignore failed responses?... 348 # Or should we show some sort of error state to the user?... 349 if result is None or state is None: 350 return 351 352 self._waiting_for_initial_state = False 353 self._waiting_for_start_stop_response = False 354 self._hostingstate = state 355 self._refresh_sub_tab() 356 357 def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None: 358 assert self._container 359 if playsound: 360 ba.playsound(ba.getsound('click01')) 361 362 # If switching from join to host, do a fresh state query. 363 if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST: 364 # Prevent taking any action until we've gotten a fresh state. 365 self._waiting_for_initial_state = True 366 367 # This will get a state query sent out immediately. 368 self._last_hosting_state_query_time = None 369 self._last_action_send_time = None # So we don't ignore response. 370 self._update() 371 372 self._state.sub_tab = value 373 active_color = (0.6, 1.0, 0.6) 374 inactive_color = (0.5, 0.4, 0.5) 375 ba.textwidget( 376 edit=self._join_sub_tab_text, 377 color=active_color if value is SubTabType.JOIN else inactive_color, 378 ) 379 ba.textwidget( 380 edit=self._host_sub_tab_text, 381 color=active_color if value is SubTabType.HOST else inactive_color, 382 ) 383 384 self._refresh_sub_tab() 385 386 # Kick off an update to get any needed messages sent/etc. 387 ba.pushcall(self._update) 388 389 def _selwidgets(self) -> list[ba.Widget | None]: 390 """An indexed list of widgets we can use for saving/restoring sel.""" 391 return [ 392 self._host_playlist_button, 393 self._host_copy_button, 394 self._host_connect_button, 395 self._host_start_stop_button, 396 self._get_tickets_button, 397 ] 398 399 def _refresh_sub_tab(self) -> None: 400 assert self._container 401 402 # Store an index for our current selection so we can 403 # reselect the equivalent recreated widget if possible. 404 selindex: int | None = None 405 selchild = self._container.get_selected_child() 406 if selchild is not None: 407 try: 408 selindex = self._selwidgets().index(selchild) 409 except ValueError: 410 pass 411 412 # Clear anything existing in the old sub-tab. 413 for widget in self._container.get_children(): 414 if widget and widget not in { 415 self._host_sub_tab_text, 416 self._join_sub_tab_text, 417 }: 418 widget.delete() 419 420 if self._state.sub_tab is SubTabType.JOIN: 421 self._build_join_tab() 422 elif self._state.sub_tab is SubTabType.HOST: 423 self._build_host_tab() 424 else: 425 raise RuntimeError('Invalid state.') 426 427 # Select the new equivalent widget if there is one. 428 if selindex is not None: 429 selwidget = self._selwidgets()[selindex] 430 if selwidget: 431 ba.containerwidget( 432 edit=self._container, selected_child=selwidget 433 ) 434 435 def _build_join_tab(self) -> None: 436 437 ba.textwidget( 438 parent=self._container, 439 position=(self._c_width * 0.5, self._c_height - 140), 440 color=(0.5, 0.46, 0.5), 441 scale=1.5, 442 size=(0, 0), 443 maxwidth=250, 444 h_align='center', 445 v_align='center', 446 text=ba.Lstr(resource='gatherWindow.partyCodeText'), 447 ) 448 449 self._join_party_code_text = ba.textwidget( 450 parent=self._container, 451 position=(self._c_width * 0.5 - 150, self._c_height - 250), 452 flatness=1.0, 453 scale=1.5, 454 size=(300, 50), 455 editable=True, 456 description=ba.Lstr(resource='gatherWindow.partyCodeText'), 457 autoselect=True, 458 maxwidth=250, 459 h_align='left', 460 v_align='center', 461 text='', 462 ) 463 btn = ba.buttonwidget( 464 parent=self._container, 465 size=(300, 70), 466 label=ba.Lstr(resource='gatherWindow.' 'manualConnectText'), 467 position=(self._c_width * 0.5 - 150, self._c_height - 350), 468 on_activate_call=self._join_connect_press, 469 autoselect=True, 470 ) 471 ba.textwidget( 472 edit=self._join_party_code_text, on_return_press_call=btn.activate 473 ) 474 475 def _on_get_tickets_press(self) -> None: 476 477 if self._waiting_for_start_stop_response: 478 return 479 480 # Bring up get-tickets window and then kill ourself (we're on the 481 # overlay layer so we'd show up above it). 482 getcurrency.GetCurrencyWindow( 483 modal=True, origin_widget=self._get_tickets_button 484 ) 485 486 def _build_host_tab(self) -> None: 487 # pylint: disable=too-many-branches 488 # pylint: disable=too-many-statements 489 490 hostingstate = self._hostingstate 491 if ba.internal.get_v1_account_state() != 'signed_in': 492 ba.textwidget( 493 parent=self._container, 494 size=(0, 0), 495 h_align='center', 496 v_align='center', 497 maxwidth=200, 498 scale=0.8, 499 color=(0.6, 0.56, 0.6), 500 position=(self._c_width * 0.5, self._c_height * 0.5), 501 text=ba.Lstr(resource='notSignedInErrorText'), 502 ) 503 self._showing_not_signed_in_screen = True 504 return 505 self._showing_not_signed_in_screen = False 506 507 # At first we don't want to show anything until we've gotten a state. 508 # Update: In this situation we now simply show our existing state 509 # but give the start/stop button a loading message and disallow its 510 # use. This keeps things a lot less jumpy looking and allows selecting 511 # playlists/etc without having to wait for the server each time 512 # back to the ui. 513 if self._waiting_for_initial_state and bool(False): 514 ba.textwidget( 515 parent=self._container, 516 size=(0, 0), 517 h_align='center', 518 v_align='center', 519 maxwidth=200, 520 scale=0.8, 521 color=(0.6, 0.56, 0.6), 522 position=(self._c_width * 0.5, self._c_height * 0.5), 523 text=ba.Lstr( 524 value='${A}...', 525 subs=[('${A}', ba.Lstr(resource='store.loadingText'))], 526 ), 527 ) 528 return 529 530 # If we're not currently hosting and hosting requires tickets, 531 # Show our count (possibly with a link to purchase more). 532 if ( 533 not self._waiting_for_initial_state 534 and hostingstate.party_code is None 535 and hostingstate.tickets_to_host_now != 0 536 ): 537 if not ba.app.ui.use_toolbars: 538 if ba.app.allow_ticket_purchases: 539 self._get_tickets_button = ba.buttonwidget( 540 parent=self._container, 541 position=( 542 self._c_width - 210 + 125, 543 self._c_height - 44, 544 ), 545 autoselect=True, 546 scale=0.6, 547 size=(120, 60), 548 textcolor=(0.2, 1, 0.2), 549 label=ba.charstr(ba.SpecialChar.TICKET), 550 color=(0.65, 0.5, 0.8), 551 on_activate_call=self._on_get_tickets_press, 552 ) 553 else: 554 self._ticket_count_text = ba.textwidget( 555 parent=self._container, 556 scale=0.6, 557 position=( 558 self._c_width - 210 + 125, 559 self._c_height - 44, 560 ), 561 color=(0.2, 1, 0.2), 562 h_align='center', 563 v_align='center', 564 ) 565 # Set initial ticket count. 566 self._update_currency_ui() 567 568 v = self._c_height - 90 569 if hostingstate.party_code is None: 570 ba.textwidget( 571 parent=self._container, 572 size=(0, 0), 573 h_align='center', 574 v_align='center', 575 maxwidth=self._c_width * 0.9, 576 scale=0.7, 577 flatness=1.0, 578 color=(0.5, 0.46, 0.5), 579 position=(self._c_width * 0.5, v), 580 text=ba.Lstr( 581 resource='gatherWindow.privatePartyCloudDescriptionText' 582 ), 583 ) 584 585 v -= 100 586 if hostingstate.party_code is None: 587 # We've got no current party running; show options to set one up. 588 ba.textwidget( 589 parent=self._container, 590 size=(0, 0), 591 h_align='right', 592 v_align='center', 593 maxwidth=200, 594 scale=0.8, 595 color=(0.6, 0.56, 0.6), 596 position=(self._c_width * 0.5 - 210, v), 597 text=ba.Lstr(resource='playlistText'), 598 ) 599 self._host_playlist_button = ba.buttonwidget( 600 parent=self._container, 601 size=(400, 70), 602 color=(0.6, 0.5, 0.6), 603 textcolor=(0.8, 0.75, 0.8), 604 label=self._hostingconfig.playlist_name, 605 on_activate_call=self._playlist_press, 606 position=(self._c_width * 0.5 - 200, v - 35), 607 up_widget=self._host_sub_tab_text, 608 autoselect=True, 609 ) 610 611 # If it appears we're coming back from playlist selection, 612 # re-select our playlist button. 613 if ba.app.ui.selecting_private_party_playlist: 614 ba.containerwidget( 615 edit=self._container, 616 selected_child=self._host_playlist_button, 617 ) 618 ba.app.ui.selecting_private_party_playlist = False 619 else: 620 # We've got a current party; show its info. 621 ba.textwidget( 622 parent=self._container, 623 size=(0, 0), 624 h_align='center', 625 v_align='center', 626 maxwidth=600, 627 scale=0.9, 628 color=(0.7, 0.64, 0.7), 629 position=(self._c_width * 0.5, v + 90), 630 text=ba.Lstr(resource='gatherWindow.partyServerRunningText'), 631 ) 632 ba.textwidget( 633 parent=self._container, 634 size=(0, 0), 635 h_align='center', 636 v_align='center', 637 maxwidth=600, 638 scale=0.7, 639 color=(0.7, 0.64, 0.7), 640 position=(self._c_width * 0.5, v + 50), 641 text=ba.Lstr(resource='gatherWindow.partyCodeText'), 642 ) 643 ba.textwidget( 644 parent=self._container, 645 size=(0, 0), 646 h_align='center', 647 v_align='center', 648 scale=2.0, 649 color=(0.0, 1.0, 0.0), 650 position=(self._c_width * 0.5, v + 10), 651 text=hostingstate.party_code, 652 ) 653 654 # Also action buttons to copy it and connect to it. 655 if ba.clipboard_is_supported(): 656 cbtnoffs = 10 657 self._host_copy_button = ba.buttonwidget( 658 parent=self._container, 659 size=(140, 40), 660 color=(0.6, 0.5, 0.6), 661 textcolor=(0.8, 0.75, 0.8), 662 label=ba.Lstr(resource='gatherWindow.copyCodeText'), 663 on_activate_call=self._host_copy_press, 664 position=(self._c_width * 0.5 - 150, v - 70), 665 autoselect=True, 666 ) 667 else: 668 cbtnoffs = -70 669 self._host_connect_button = ba.buttonwidget( 670 parent=self._container, 671 size=(140, 40), 672 color=(0.6, 0.5, 0.6), 673 textcolor=(0.8, 0.75, 0.8), 674 label=ba.Lstr(resource='gatherWindow.manualConnectText'), 675 on_activate_call=self._host_connect_press, 676 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 677 autoselect=True, 678 ) 679 680 v -= 120 681 682 # Line above the main action button: 683 684 # If we don't want to show anything until we get a state: 685 if self._waiting_for_initial_state: 686 pass 687 elif hostingstate.unavailable_error is not None: 688 # If hosting is unavailable, show the associated reason. 689 ba.textwidget( 690 parent=self._container, 691 size=(0, 0), 692 h_align='center', 693 v_align='center', 694 maxwidth=self._c_width * 0.9, 695 scale=0.7, 696 flatness=1.0, 697 color=(1.0, 0.0, 0.0), 698 position=(self._c_width * 0.5, v), 699 text=ba.Lstr( 700 translate=( 701 'serverResponses', 702 hostingstate.unavailable_error, 703 ) 704 ), 705 ) 706 elif hostingstate.free_host_minutes_remaining is not None: 707 # If we've been pre-approved to start/stop for free, show that. 708 ba.textwidget( 709 parent=self._container, 710 size=(0, 0), 711 h_align='center', 712 v_align='center', 713 maxwidth=self._c_width * 0.9, 714 scale=0.7, 715 flatness=1.0, 716 color=( 717 (0.7, 0.64, 0.7) 718 if hostingstate.party_code 719 else (0.0, 1.0, 0.0) 720 ), 721 position=(self._c_width * 0.5, v), 722 text=ba.Lstr( 723 resource='gatherWindow.startStopHostingMinutesText', 724 subs=[ 725 ( 726 '${MINUTES}', 727 f'{hostingstate.free_host_minutes_remaining:.0f}', 728 ) 729 ], 730 ), 731 ) 732 else: 733 # Otherwise tell whether the free cloud server is available 734 # or will be at some point. 735 if hostingstate.party_code is None: 736 if hostingstate.tickets_to_host_now == 0: 737 ba.textwidget( 738 parent=self._container, 739 size=(0, 0), 740 h_align='center', 741 v_align='center', 742 maxwidth=self._c_width * 0.9, 743 scale=0.7, 744 flatness=1.0, 745 color=(0.0, 1.0, 0.0), 746 position=(self._c_width * 0.5, v), 747 text=ba.Lstr( 748 resource=( 749 'gatherWindow.freeCloudServerAvailableNowText' 750 ) 751 ), 752 ) 753 else: 754 if hostingstate.minutes_until_free_host is None: 755 ba.textwidget( 756 parent=self._container, 757 size=(0, 0), 758 h_align='center', 759 v_align='center', 760 maxwidth=self._c_width * 0.9, 761 scale=0.7, 762 flatness=1.0, 763 color=(1.0, 0.6, 0.0), 764 position=(self._c_width * 0.5, v), 765 text=ba.Lstr( 766 resource=( 767 'gatherWindow' 768 '.freeCloudServerNotAvailableText' 769 ) 770 ), 771 ) 772 else: 773 availmins = hostingstate.minutes_until_free_host 774 ba.textwidget( 775 parent=self._container, 776 size=(0, 0), 777 h_align='center', 778 v_align='center', 779 maxwidth=self._c_width * 0.9, 780 scale=0.7, 781 flatness=1.0, 782 color=(1.0, 0.6, 0.0), 783 position=(self._c_width * 0.5, v), 784 text=ba.Lstr( 785 resource='gatherWindow.' 786 'freeCloudServerAvailableMinutesText', 787 subs=[('${MINUTES}', f'{availmins:.0f}')], 788 ), 789 ) 790 791 v -= 100 792 793 if ( 794 self._waiting_for_start_stop_response 795 or self._waiting_for_initial_state 796 ): 797 btnlabel = ba.Lstr(resource='oneMomentText') 798 else: 799 if hostingstate.unavailable_error is not None: 800 btnlabel = ba.Lstr( 801 resource='gatherWindow.hostingUnavailableText' 802 ) 803 elif hostingstate.party_code is None: 804 ticon = ba.internal.charstr(ba.SpecialChar.TICKET) 805 nowtickets = hostingstate.tickets_to_host_now 806 if nowtickets > 0: 807 btnlabel = ba.Lstr( 808 resource='gatherWindow.startHostingPaidText', 809 subs=[('${COST}', f'{ticon}{nowtickets}')], 810 ) 811 else: 812 btnlabel = ba.Lstr(resource='gatherWindow.startHostingText') 813 else: 814 btnlabel = ba.Lstr(resource='gatherWindow.stopHostingText') 815 816 disabled = ( 817 hostingstate.unavailable_error is not None 818 or self._waiting_for_initial_state 819 ) 820 waiting = self._waiting_for_start_stop_response 821 self._host_start_stop_button = ba.buttonwidget( 822 parent=self._container, 823 size=(400, 80), 824 color=( 825 (0.6, 0.6, 0.6) 826 if disabled 827 else (0.5, 1.0, 0.5) 828 if waiting 829 else None 830 ), 831 enable_sound=False, 832 label=btnlabel, 833 textcolor=((0.7, 0.7, 0.7) if disabled else None), 834 position=(self._c_width * 0.5 - 200, v), 835 on_activate_call=self._start_stop_button_press, 836 autoselect=True, 837 ) 838 839 def _playlist_press(self) -> None: 840 assert self._host_playlist_button is not None 841 self.window.playlist_select(origin_widget=self._host_playlist_button) 842 843 def _host_copy_press(self) -> None: 844 assert self._hostingstate.party_code is not None 845 ba.clipboard_set_text(self._hostingstate.party_code) 846 ba.screenmessage(ba.Lstr(resource='gatherWindow.copyCodeConfirmText')) 847 848 def _host_connect_press(self) -> None: 849 assert self._hostingstate.party_code is not None 850 self._connect_to_party_code(self._hostingstate.party_code) 851 852 def _debug_server_comm(self, msg: str) -> None: 853 if DEBUG_SERVER_COMMUNICATION: 854 print( 855 f'PPTABCOM: {msg} at time ' 856 f'{time.time()-self._create_time:.2f}' 857 ) 858 859 def _connect_to_party_code(self, code: str) -> None: 860 861 # Ignore attempted followup sends for a few seconds. 862 # (this will reset if we get a response) 863 now = time.time() 864 if ( 865 self._connect_press_time is not None 866 and now - self._connect_press_time < 5.0 867 ): 868 self._debug_server_comm( 869 'not sending private party connect (too soon)' 870 ) 871 return 872 self._connect_press_time = now 873 874 self._debug_server_comm('sending private party connect') 875 ba.internal.add_transaction( 876 { 877 'type': 'PRIVATE_PARTY_CONNECT', 878 'expire_time': time.time() + 20, 879 'code': code, 880 }, 881 callback=ba.WeakCall(self._connect_response), 882 ) 883 ba.internal.run_transactions() 884 885 def _start_stop_button_press(self) -> None: 886 if ( 887 self._waiting_for_start_stop_response 888 or self._waiting_for_initial_state 889 ): 890 return 891 892 if ba.internal.get_v1_account_state() != 'signed_in': 893 ba.screenmessage(ba.Lstr(resource='notSignedInErrorText')) 894 ba.playsound(ba.getsound('error')) 895 self._refresh_sub_tab() 896 return 897 898 if self._hostingstate.unavailable_error is not None: 899 ba.playsound(ba.getsound('error')) 900 return 901 902 ba.playsound(ba.getsound('click01')) 903 904 # If we're not hosting, start. 905 if self._hostingstate.party_code is None: 906 907 # If there's a ticket cost, make sure we have enough tickets. 908 if self._hostingstate.tickets_to_host_now > 0: 909 ticket_count: int | None 910 try: 911 ticket_count = ba.internal.get_v1_account_ticket_count() 912 except Exception: 913 # FIXME: should add a ba.NotSignedInError we can use here. 914 ticket_count = None 915 ticket_cost = self._hostingstate.tickets_to_host_now 916 if ticket_count is not None and ticket_count < ticket_cost: 917 getcurrency.show_get_tickets_prompt() 918 ba.playsound(ba.getsound('error')) 919 return 920 self._last_action_send_time = time.time() 921 ba.internal.add_transaction( 922 { 923 'type': 'PRIVATE_PARTY_START', 924 'config': dataclass_to_dict(self._hostingconfig), 925 'region_pings': ba.app.net.zone_pings, 926 'expire_time': time.time() + 20, 927 }, 928 callback=ba.WeakCall(self._hosting_state_response), 929 ) 930 ba.internal.run_transactions() 931 932 else: 933 self._last_action_send_time = time.time() 934 ba.internal.add_transaction( 935 { 936 'type': 'PRIVATE_PARTY_STOP', 937 'expire_time': time.time() + 20, 938 }, 939 callback=ba.WeakCall(self._hosting_state_response), 940 ) 941 ba.internal.run_transactions() 942 ba.playsound(ba.getsound('click01')) 943 944 self._waiting_for_start_stop_response = True 945 self._refresh_sub_tab() 946 947 def _join_connect_press(self) -> None: 948 949 # Error immediately if its an empty code. 950 code: str | None = None 951 if self._join_party_code_text: 952 code = cast(str, ba.textwidget(query=self._join_party_code_text)) 953 if not code: 954 ba.screenmessage( 955 ba.Lstr(resource='internal.invalidAddressErrorText'), 956 color=(1, 0, 0), 957 ) 958 ba.playsound(ba.getsound('error')) 959 return 960 961 self._connect_to_party_code(code) 962 963 def _connect_response(self, result: dict[str, Any] | None) -> None: 964 try: 965 self._connect_press_time = None 966 if result is None: 967 raise RuntimeError() 968 cresult = dataclass_from_dict( 969 PrivatePartyConnectResult, result, discard_unknown_attrs=True 970 ) 971 if cresult.error is not None: 972 self._debug_server_comm('got error connect response') 973 ba.screenmessage( 974 ba.Lstr(translate=('serverResponses', cresult.error)), 975 (1, 0, 0), 976 ) 977 ba.playsound(ba.getsound('error')) 978 return 979 self._debug_server_comm('got valid connect response') 980 assert cresult.addr is not None and cresult.port is not None 981 ba.internal.connect_to_party(cresult.addr, port=cresult.port) 982 except Exception: 983 self._debug_server_comm('got connect response error') 984 ba.playsound(ba.getsound('error')) 985 986 def save_state(self) -> None: 987 ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state) 988 989 def restore_state(self) -> None: 990 state = ba.app.ui.window_states.get(type(self)) 991 if state is None: 992 state = State() 993 assert isinstance(state, State) 994 self._state = state
class
SubTabType(enum.Enum):
Available sub-tabs.
JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
- enum.Enum
- name
- value
@dataclass
class
State:
41@dataclass 42class State: 43 """Our core state that persists while the app is running.""" 44 45 sub_tab: SubTabType = SubTabType.JOIN
Our core state that persists while the app is running.
State( sub_tab: bastd.ui.gather.privatetab.SubTabType = <SubTabType.JOIN: 'join'>)
48class PrivateGatherTab(GatherTab): 49 """The private tab in the gather UI""" 50 51 def __init__(self, window: GatherWindow) -> None: 52 super().__init__(window) 53 self._container: ba.Widget | None = None 54 self._state: State = State() 55 self._hostingstate = PrivateHostingState() 56 self._join_sub_tab_text: ba.Widget | None = None 57 self._host_sub_tab_text: ba.Widget | None = None 58 self._update_timer: ba.Timer | None = None 59 self._join_party_code_text: ba.Widget | None = None 60 self._c_width: float = 0.0 61 self._c_height: float = 0.0 62 self._last_hosting_state_query_time: float | None = None 63 self._waiting_for_initial_state = True 64 self._waiting_for_start_stop_response = True 65 self._host_playlist_button: ba.Widget | None = None 66 self._host_copy_button: ba.Widget | None = None 67 self._host_connect_button: ba.Widget | None = None 68 self._host_start_stop_button: ba.Widget | None = None 69 self._get_tickets_button: ba.Widget | None = None 70 self._ticket_count_text: ba.Widget | None = None 71 self._showing_not_signed_in_screen = False 72 self._create_time = time.time() 73 self._last_action_send_time: float | None = None 74 self._connect_press_time: float | None = None 75 try: 76 self._hostingconfig = self._build_hosting_config() 77 except Exception: 78 ba.print_exception('Error building hosting config') 79 self._hostingconfig = PrivateHostingConfig() 80 81 def on_activate( 82 self, 83 parent_widget: ba.Widget, 84 tab_button: ba.Widget, 85 region_width: float, 86 region_height: float, 87 region_left: float, 88 region_bottom: float, 89 ) -> ba.Widget: 90 self._c_width = region_width 91 self._c_height = region_height - 20 92 self._container = ba.containerwidget( 93 parent=parent_widget, 94 position=( 95 region_left, 96 region_bottom + (region_height - self._c_height) * 0.5, 97 ), 98 size=(self._c_width, self._c_height), 99 background=False, 100 selection_loops_to_parent=True, 101 ) 102 v = self._c_height - 30.0 103 self._join_sub_tab_text = ba.textwidget( 104 parent=self._container, 105 position=(self._c_width * 0.5 - 245, v - 13), 106 color=(0.6, 1.0, 0.6), 107 scale=1.3, 108 size=(200, 30), 109 maxwidth=250, 110 h_align='left', 111 v_align='center', 112 click_activate=True, 113 selectable=True, 114 autoselect=True, 115 on_activate_call=lambda: self._set_sub_tab( 116 SubTabType.JOIN, 117 playsound=True, 118 ), 119 text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'), 120 ) 121 self._host_sub_tab_text = ba.textwidget( 122 parent=self._container, 123 position=(self._c_width * 0.5 + 45, v - 13), 124 color=(0.6, 1.0, 0.6), 125 scale=1.3, 126 size=(200, 30), 127 maxwidth=250, 128 h_align='left', 129 v_align='center', 130 click_activate=True, 131 selectable=True, 132 autoselect=True, 133 on_activate_call=lambda: self._set_sub_tab( 134 SubTabType.HOST, 135 playsound=True, 136 ), 137 text=ba.Lstr(resource='gatherWindow.privatePartyHostText'), 138 ) 139 ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 140 ba.widget( 141 edit=self._host_sub_tab_text, 142 left_widget=self._join_sub_tab_text, 143 up_widget=tab_button, 144 ) 145 ba.widget( 146 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 147 ) 148 149 self._update_timer = ba.Timer( 150 1.0, 151 ba.WeakCall(self._update), 152 repeat=True, 153 timetype=ba.TimeType.REAL, 154 ) 155 156 # Prevent taking any action until we've updated our state. 157 self._waiting_for_initial_state = True 158 159 # This will get a state query sent out immediately. 160 self._last_action_send_time = None # Ensure we don't ignore response. 161 self._last_hosting_state_query_time = None 162 self._update() 163 164 self._set_sub_tab(self._state.sub_tab) 165 166 return self._container 167 168 def _build_hosting_config(self) -> PrivateHostingConfig: 169 # pylint: disable=too-many-branches 170 from bastd.ui.playlist import PlaylistTypeVars 171 from ba.internal import filter_playlist 172 173 hcfg = PrivateHostingConfig() 174 cfg = ba.app.config 175 sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa') 176 if not isinstance(sessiontypestr, str): 177 raise RuntimeError(f'Invalid sessiontype {sessiontypestr}') 178 hcfg.session_type = sessiontypestr 179 180 sessiontype: type[ba.Session] 181 if hcfg.session_type == 'ffa': 182 sessiontype = ba.FreeForAllSession 183 elif hcfg.session_type == 'teams': 184 sessiontype = ba.DualTeamSession 185 else: 186 raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}') 187 pvars = PlaylistTypeVars(sessiontype) 188 189 playlist_name = ba.app.config.get( 190 f'{pvars.config_name} Playlist Selection' 191 ) 192 if not isinstance(playlist_name, str): 193 playlist_name = '__default__' 194 hcfg.playlist_name = ( 195 pvars.default_list_name.evaluate() 196 if playlist_name == '__default__' 197 else playlist_name 198 ) 199 200 playlist: list[dict[str, Any]] | None = None 201 if playlist_name != '__default__': 202 playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get( 203 playlist_name 204 ) 205 if playlist is None: 206 playlist = pvars.get_default_list_call() 207 208 hcfg.playlist = filter_playlist( 209 playlist, sessiontype, name=playlist_name 210 ) 211 212 randomize = cfg.get(f'{pvars.config_name} Playlist Randomize') 213 if not isinstance(randomize, bool): 214 randomize = False 215 hcfg.randomize = randomize 216 217 tutorial = cfg.get('Show Tutorial') 218 if not isinstance(tutorial, bool): 219 tutorial = True 220 hcfg.tutorial = tutorial 221 222 if hcfg.session_type == 'teams': 223 ctn: list[str] | None = cfg.get('Custom Team Names') 224 if ctn is not None: 225 if ( 226 isinstance(ctn, (list, tuple)) 227 and len(ctn) == 2 228 and all(isinstance(x, str) for x in ctn) 229 ): 230 hcfg.custom_team_names = (ctn[0], ctn[1]) 231 else: 232 print(f'Found invalid custom-team-names data: {ctn}') 233 234 ctc: list[list[float]] | None = cfg.get('Custom Team Colors') 235 if ctc is not None: 236 if ( 237 isinstance(ctc, (list, tuple)) 238 and len(ctc) == 2 239 and all(isinstance(x, (list, tuple)) for x in ctc) 240 and all(len(x) == 3 for x in ctc) 241 ): 242 hcfg.custom_team_colors = ( 243 (ctc[0][0], ctc[0][1], ctc[0][2]), 244 (ctc[1][0], ctc[1][1], ctc[1][2]), 245 ) 246 else: 247 print(f'Found invalid custom-team-colors data: {ctc}') 248 249 return hcfg 250 251 def on_deactivate(self) -> None: 252 self._update_timer = None 253 254 def _update_currency_ui(self) -> None: 255 # Keep currency count up to date if applicable. 256 try: 257 t_str = str(ba.internal.get_v1_account_ticket_count()) 258 except Exception: 259 t_str = '?' 260 if self._get_tickets_button: 261 ba.buttonwidget( 262 edit=self._get_tickets_button, 263 label=ba.charstr(ba.SpecialChar.TICKET) + t_str, 264 ) 265 if self._ticket_count_text: 266 ba.textwidget( 267 edit=self._ticket_count_text, 268 text=ba.charstr(ba.SpecialChar.TICKET) + t_str, 269 ) 270 271 def _update(self) -> None: 272 """Periodic updating.""" 273 274 now = ba.time(ba.TimeType.REAL) 275 276 self._update_currency_ui() 277 278 if self._state.sub_tab is SubTabType.HOST: 279 280 # If we're not signed in, just refresh to show that. 281 if ( 282 ba.internal.get_v1_account_state() != 'signed_in' 283 and self._showing_not_signed_in_screen 284 ): 285 self._refresh_sub_tab() 286 else: 287 288 # Query an updated state periodically. 289 if ( 290 self._last_hosting_state_query_time is None 291 or now - self._last_hosting_state_query_time > 15.0 292 ): 293 self._debug_server_comm('querying private party state') 294 if ba.internal.get_v1_account_state() == 'signed_in': 295 ba.internal.add_transaction( 296 { 297 'type': 'PRIVATE_PARTY_QUERY', 298 'expire_time': time.time() + 20, 299 }, 300 callback=ba.WeakCall( 301 self._hosting_state_idle_response 302 ), 303 ) 304 ba.internal.run_transactions() 305 else: 306 self._hosting_state_idle_response(None) 307 self._last_hosting_state_query_time = now 308 309 def _hosting_state_idle_response( 310 self, result: dict[str, Any] | None 311 ) -> None: 312 313 # This simply passes through to our standard response handler. 314 # The one exception is if we've recently sent an action to the 315 # server (start/stop hosting/etc.) In that case we want to ignore 316 # idle background updates and wait for the response to our action. 317 # (this keeps the button showing 'one moment...' until the change 318 # takes effect, etc.) 319 if ( 320 self._last_action_send_time is not None 321 and time.time() - self._last_action_send_time < 5.0 322 ): 323 self._debug_server_comm( 324 'ignoring private party state response due to recent action' 325 ) 326 return 327 self._hosting_state_response(result) 328 329 def _hosting_state_response(self, result: dict[str, Any] | None) -> None: 330 331 # Its possible for this to come back to us after our UI is dead; 332 # ignore in that case. 333 if not self._container: 334 return 335 336 state: PrivateHostingState | None = None 337 if result is not None: 338 self._debug_server_comm('got private party state response') 339 try: 340 state = dataclass_from_dict( 341 PrivateHostingState, result, discard_unknown_attrs=True 342 ) 343 except Exception: 344 ba.print_exception('Got invalid PrivateHostingState data') 345 else: 346 self._debug_server_comm('private party state response errored') 347 348 # Hmm I guess let's just ignore failed responses?... 349 # Or should we show some sort of error state to the user?... 350 if result is None or state is None: 351 return 352 353 self._waiting_for_initial_state = False 354 self._waiting_for_start_stop_response = False 355 self._hostingstate = state 356 self._refresh_sub_tab() 357 358 def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None: 359 assert self._container 360 if playsound: 361 ba.playsound(ba.getsound('click01')) 362 363 # If switching from join to host, do a fresh state query. 364 if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST: 365 # Prevent taking any action until we've gotten a fresh state. 366 self._waiting_for_initial_state = True 367 368 # This will get a state query sent out immediately. 369 self._last_hosting_state_query_time = None 370 self._last_action_send_time = None # So we don't ignore response. 371 self._update() 372 373 self._state.sub_tab = value 374 active_color = (0.6, 1.0, 0.6) 375 inactive_color = (0.5, 0.4, 0.5) 376 ba.textwidget( 377 edit=self._join_sub_tab_text, 378 color=active_color if value is SubTabType.JOIN else inactive_color, 379 ) 380 ba.textwidget( 381 edit=self._host_sub_tab_text, 382 color=active_color if value is SubTabType.HOST else inactive_color, 383 ) 384 385 self._refresh_sub_tab() 386 387 # Kick off an update to get any needed messages sent/etc. 388 ba.pushcall(self._update) 389 390 def _selwidgets(self) -> list[ba.Widget | None]: 391 """An indexed list of widgets we can use for saving/restoring sel.""" 392 return [ 393 self._host_playlist_button, 394 self._host_copy_button, 395 self._host_connect_button, 396 self._host_start_stop_button, 397 self._get_tickets_button, 398 ] 399 400 def _refresh_sub_tab(self) -> None: 401 assert self._container 402 403 # Store an index for our current selection so we can 404 # reselect the equivalent recreated widget if possible. 405 selindex: int | None = None 406 selchild = self._container.get_selected_child() 407 if selchild is not None: 408 try: 409 selindex = self._selwidgets().index(selchild) 410 except ValueError: 411 pass 412 413 # Clear anything existing in the old sub-tab. 414 for widget in self._container.get_children(): 415 if widget and widget not in { 416 self._host_sub_tab_text, 417 self._join_sub_tab_text, 418 }: 419 widget.delete() 420 421 if self._state.sub_tab is SubTabType.JOIN: 422 self._build_join_tab() 423 elif self._state.sub_tab is SubTabType.HOST: 424 self._build_host_tab() 425 else: 426 raise RuntimeError('Invalid state.') 427 428 # Select the new equivalent widget if there is one. 429 if selindex is not None: 430 selwidget = self._selwidgets()[selindex] 431 if selwidget: 432 ba.containerwidget( 433 edit=self._container, selected_child=selwidget 434 ) 435 436 def _build_join_tab(self) -> None: 437 438 ba.textwidget( 439 parent=self._container, 440 position=(self._c_width * 0.5, self._c_height - 140), 441 color=(0.5, 0.46, 0.5), 442 scale=1.5, 443 size=(0, 0), 444 maxwidth=250, 445 h_align='center', 446 v_align='center', 447 text=ba.Lstr(resource='gatherWindow.partyCodeText'), 448 ) 449 450 self._join_party_code_text = ba.textwidget( 451 parent=self._container, 452 position=(self._c_width * 0.5 - 150, self._c_height - 250), 453 flatness=1.0, 454 scale=1.5, 455 size=(300, 50), 456 editable=True, 457 description=ba.Lstr(resource='gatherWindow.partyCodeText'), 458 autoselect=True, 459 maxwidth=250, 460 h_align='left', 461 v_align='center', 462 text='', 463 ) 464 btn = ba.buttonwidget( 465 parent=self._container, 466 size=(300, 70), 467 label=ba.Lstr(resource='gatherWindow.' 'manualConnectText'), 468 position=(self._c_width * 0.5 - 150, self._c_height - 350), 469 on_activate_call=self._join_connect_press, 470 autoselect=True, 471 ) 472 ba.textwidget( 473 edit=self._join_party_code_text, on_return_press_call=btn.activate 474 ) 475 476 def _on_get_tickets_press(self) -> None: 477 478 if self._waiting_for_start_stop_response: 479 return 480 481 # Bring up get-tickets window and then kill ourself (we're on the 482 # overlay layer so we'd show up above it). 483 getcurrency.GetCurrencyWindow( 484 modal=True, origin_widget=self._get_tickets_button 485 ) 486 487 def _build_host_tab(self) -> None: 488 # pylint: disable=too-many-branches 489 # pylint: disable=too-many-statements 490 491 hostingstate = self._hostingstate 492 if ba.internal.get_v1_account_state() != 'signed_in': 493 ba.textwidget( 494 parent=self._container, 495 size=(0, 0), 496 h_align='center', 497 v_align='center', 498 maxwidth=200, 499 scale=0.8, 500 color=(0.6, 0.56, 0.6), 501 position=(self._c_width * 0.5, self._c_height * 0.5), 502 text=ba.Lstr(resource='notSignedInErrorText'), 503 ) 504 self._showing_not_signed_in_screen = True 505 return 506 self._showing_not_signed_in_screen = False 507 508 # At first we don't want to show anything until we've gotten a state. 509 # Update: In this situation we now simply show our existing state 510 # but give the start/stop button a loading message and disallow its 511 # use. This keeps things a lot less jumpy looking and allows selecting 512 # playlists/etc without having to wait for the server each time 513 # back to the ui. 514 if self._waiting_for_initial_state and bool(False): 515 ba.textwidget( 516 parent=self._container, 517 size=(0, 0), 518 h_align='center', 519 v_align='center', 520 maxwidth=200, 521 scale=0.8, 522 color=(0.6, 0.56, 0.6), 523 position=(self._c_width * 0.5, self._c_height * 0.5), 524 text=ba.Lstr( 525 value='${A}...', 526 subs=[('${A}', ba.Lstr(resource='store.loadingText'))], 527 ), 528 ) 529 return 530 531 # If we're not currently hosting and hosting requires tickets, 532 # Show our count (possibly with a link to purchase more). 533 if ( 534 not self._waiting_for_initial_state 535 and hostingstate.party_code is None 536 and hostingstate.tickets_to_host_now != 0 537 ): 538 if not ba.app.ui.use_toolbars: 539 if ba.app.allow_ticket_purchases: 540 self._get_tickets_button = ba.buttonwidget( 541 parent=self._container, 542 position=( 543 self._c_width - 210 + 125, 544 self._c_height - 44, 545 ), 546 autoselect=True, 547 scale=0.6, 548 size=(120, 60), 549 textcolor=(0.2, 1, 0.2), 550 label=ba.charstr(ba.SpecialChar.TICKET), 551 color=(0.65, 0.5, 0.8), 552 on_activate_call=self._on_get_tickets_press, 553 ) 554 else: 555 self._ticket_count_text = ba.textwidget( 556 parent=self._container, 557 scale=0.6, 558 position=( 559 self._c_width - 210 + 125, 560 self._c_height - 44, 561 ), 562 color=(0.2, 1, 0.2), 563 h_align='center', 564 v_align='center', 565 ) 566 # Set initial ticket count. 567 self._update_currency_ui() 568 569 v = self._c_height - 90 570 if hostingstate.party_code is None: 571 ba.textwidget( 572 parent=self._container, 573 size=(0, 0), 574 h_align='center', 575 v_align='center', 576 maxwidth=self._c_width * 0.9, 577 scale=0.7, 578 flatness=1.0, 579 color=(0.5, 0.46, 0.5), 580 position=(self._c_width * 0.5, v), 581 text=ba.Lstr( 582 resource='gatherWindow.privatePartyCloudDescriptionText' 583 ), 584 ) 585 586 v -= 100 587 if hostingstate.party_code is None: 588 # We've got no current party running; show options to set one up. 589 ba.textwidget( 590 parent=self._container, 591 size=(0, 0), 592 h_align='right', 593 v_align='center', 594 maxwidth=200, 595 scale=0.8, 596 color=(0.6, 0.56, 0.6), 597 position=(self._c_width * 0.5 - 210, v), 598 text=ba.Lstr(resource='playlistText'), 599 ) 600 self._host_playlist_button = ba.buttonwidget( 601 parent=self._container, 602 size=(400, 70), 603 color=(0.6, 0.5, 0.6), 604 textcolor=(0.8, 0.75, 0.8), 605 label=self._hostingconfig.playlist_name, 606 on_activate_call=self._playlist_press, 607 position=(self._c_width * 0.5 - 200, v - 35), 608 up_widget=self._host_sub_tab_text, 609 autoselect=True, 610 ) 611 612 # If it appears we're coming back from playlist selection, 613 # re-select our playlist button. 614 if ba.app.ui.selecting_private_party_playlist: 615 ba.containerwidget( 616 edit=self._container, 617 selected_child=self._host_playlist_button, 618 ) 619 ba.app.ui.selecting_private_party_playlist = False 620 else: 621 # We've got a current party; show its info. 622 ba.textwidget( 623 parent=self._container, 624 size=(0, 0), 625 h_align='center', 626 v_align='center', 627 maxwidth=600, 628 scale=0.9, 629 color=(0.7, 0.64, 0.7), 630 position=(self._c_width * 0.5, v + 90), 631 text=ba.Lstr(resource='gatherWindow.partyServerRunningText'), 632 ) 633 ba.textwidget( 634 parent=self._container, 635 size=(0, 0), 636 h_align='center', 637 v_align='center', 638 maxwidth=600, 639 scale=0.7, 640 color=(0.7, 0.64, 0.7), 641 position=(self._c_width * 0.5, v + 50), 642 text=ba.Lstr(resource='gatherWindow.partyCodeText'), 643 ) 644 ba.textwidget( 645 parent=self._container, 646 size=(0, 0), 647 h_align='center', 648 v_align='center', 649 scale=2.0, 650 color=(0.0, 1.0, 0.0), 651 position=(self._c_width * 0.5, v + 10), 652 text=hostingstate.party_code, 653 ) 654 655 # Also action buttons to copy it and connect to it. 656 if ba.clipboard_is_supported(): 657 cbtnoffs = 10 658 self._host_copy_button = ba.buttonwidget( 659 parent=self._container, 660 size=(140, 40), 661 color=(0.6, 0.5, 0.6), 662 textcolor=(0.8, 0.75, 0.8), 663 label=ba.Lstr(resource='gatherWindow.copyCodeText'), 664 on_activate_call=self._host_copy_press, 665 position=(self._c_width * 0.5 - 150, v - 70), 666 autoselect=True, 667 ) 668 else: 669 cbtnoffs = -70 670 self._host_connect_button = ba.buttonwidget( 671 parent=self._container, 672 size=(140, 40), 673 color=(0.6, 0.5, 0.6), 674 textcolor=(0.8, 0.75, 0.8), 675 label=ba.Lstr(resource='gatherWindow.manualConnectText'), 676 on_activate_call=self._host_connect_press, 677 position=(self._c_width * 0.5 + cbtnoffs, v - 70), 678 autoselect=True, 679 ) 680 681 v -= 120 682 683 # Line above the main action button: 684 685 # If we don't want to show anything until we get a state: 686 if self._waiting_for_initial_state: 687 pass 688 elif hostingstate.unavailable_error is not None: 689 # If hosting is unavailable, show the associated reason. 690 ba.textwidget( 691 parent=self._container, 692 size=(0, 0), 693 h_align='center', 694 v_align='center', 695 maxwidth=self._c_width * 0.9, 696 scale=0.7, 697 flatness=1.0, 698 color=(1.0, 0.0, 0.0), 699 position=(self._c_width * 0.5, v), 700 text=ba.Lstr( 701 translate=( 702 'serverResponses', 703 hostingstate.unavailable_error, 704 ) 705 ), 706 ) 707 elif hostingstate.free_host_minutes_remaining is not None: 708 # If we've been pre-approved to start/stop for free, show that. 709 ba.textwidget( 710 parent=self._container, 711 size=(0, 0), 712 h_align='center', 713 v_align='center', 714 maxwidth=self._c_width * 0.9, 715 scale=0.7, 716 flatness=1.0, 717 color=( 718 (0.7, 0.64, 0.7) 719 if hostingstate.party_code 720 else (0.0, 1.0, 0.0) 721 ), 722 position=(self._c_width * 0.5, v), 723 text=ba.Lstr( 724 resource='gatherWindow.startStopHostingMinutesText', 725 subs=[ 726 ( 727 '${MINUTES}', 728 f'{hostingstate.free_host_minutes_remaining:.0f}', 729 ) 730 ], 731 ), 732 ) 733 else: 734 # Otherwise tell whether the free cloud server is available 735 # or will be at some point. 736 if hostingstate.party_code is None: 737 if hostingstate.tickets_to_host_now == 0: 738 ba.textwidget( 739 parent=self._container, 740 size=(0, 0), 741 h_align='center', 742 v_align='center', 743 maxwidth=self._c_width * 0.9, 744 scale=0.7, 745 flatness=1.0, 746 color=(0.0, 1.0, 0.0), 747 position=(self._c_width * 0.5, v), 748 text=ba.Lstr( 749 resource=( 750 'gatherWindow.freeCloudServerAvailableNowText' 751 ) 752 ), 753 ) 754 else: 755 if hostingstate.minutes_until_free_host is None: 756 ba.textwidget( 757 parent=self._container, 758 size=(0, 0), 759 h_align='center', 760 v_align='center', 761 maxwidth=self._c_width * 0.9, 762 scale=0.7, 763 flatness=1.0, 764 color=(1.0, 0.6, 0.0), 765 position=(self._c_width * 0.5, v), 766 text=ba.Lstr( 767 resource=( 768 'gatherWindow' 769 '.freeCloudServerNotAvailableText' 770 ) 771 ), 772 ) 773 else: 774 availmins = hostingstate.minutes_until_free_host 775 ba.textwidget( 776 parent=self._container, 777 size=(0, 0), 778 h_align='center', 779 v_align='center', 780 maxwidth=self._c_width * 0.9, 781 scale=0.7, 782 flatness=1.0, 783 color=(1.0, 0.6, 0.0), 784 position=(self._c_width * 0.5, v), 785 text=ba.Lstr( 786 resource='gatherWindow.' 787 'freeCloudServerAvailableMinutesText', 788 subs=[('${MINUTES}', f'{availmins:.0f}')], 789 ), 790 ) 791 792 v -= 100 793 794 if ( 795 self._waiting_for_start_stop_response 796 or self._waiting_for_initial_state 797 ): 798 btnlabel = ba.Lstr(resource='oneMomentText') 799 else: 800 if hostingstate.unavailable_error is not None: 801 btnlabel = ba.Lstr( 802 resource='gatherWindow.hostingUnavailableText' 803 ) 804 elif hostingstate.party_code is None: 805 ticon = ba.internal.charstr(ba.SpecialChar.TICKET) 806 nowtickets = hostingstate.tickets_to_host_now 807 if nowtickets > 0: 808 btnlabel = ba.Lstr( 809 resource='gatherWindow.startHostingPaidText', 810 subs=[('${COST}', f'{ticon}{nowtickets}')], 811 ) 812 else: 813 btnlabel = ba.Lstr(resource='gatherWindow.startHostingText') 814 else: 815 btnlabel = ba.Lstr(resource='gatherWindow.stopHostingText') 816 817 disabled = ( 818 hostingstate.unavailable_error is not None 819 or self._waiting_for_initial_state 820 ) 821 waiting = self._waiting_for_start_stop_response 822 self._host_start_stop_button = ba.buttonwidget( 823 parent=self._container, 824 size=(400, 80), 825 color=( 826 (0.6, 0.6, 0.6) 827 if disabled 828 else (0.5, 1.0, 0.5) 829 if waiting 830 else None 831 ), 832 enable_sound=False, 833 label=btnlabel, 834 textcolor=((0.7, 0.7, 0.7) if disabled else None), 835 position=(self._c_width * 0.5 - 200, v), 836 on_activate_call=self._start_stop_button_press, 837 autoselect=True, 838 ) 839 840 def _playlist_press(self) -> None: 841 assert self._host_playlist_button is not None 842 self.window.playlist_select(origin_widget=self._host_playlist_button) 843 844 def _host_copy_press(self) -> None: 845 assert self._hostingstate.party_code is not None 846 ba.clipboard_set_text(self._hostingstate.party_code) 847 ba.screenmessage(ba.Lstr(resource='gatherWindow.copyCodeConfirmText')) 848 849 def _host_connect_press(self) -> None: 850 assert self._hostingstate.party_code is not None 851 self._connect_to_party_code(self._hostingstate.party_code) 852 853 def _debug_server_comm(self, msg: str) -> None: 854 if DEBUG_SERVER_COMMUNICATION: 855 print( 856 f'PPTABCOM: {msg} at time ' 857 f'{time.time()-self._create_time:.2f}' 858 ) 859 860 def _connect_to_party_code(self, code: str) -> None: 861 862 # Ignore attempted followup sends for a few seconds. 863 # (this will reset if we get a response) 864 now = time.time() 865 if ( 866 self._connect_press_time is not None 867 and now - self._connect_press_time < 5.0 868 ): 869 self._debug_server_comm( 870 'not sending private party connect (too soon)' 871 ) 872 return 873 self._connect_press_time = now 874 875 self._debug_server_comm('sending private party connect') 876 ba.internal.add_transaction( 877 { 878 'type': 'PRIVATE_PARTY_CONNECT', 879 'expire_time': time.time() + 20, 880 'code': code, 881 }, 882 callback=ba.WeakCall(self._connect_response), 883 ) 884 ba.internal.run_transactions() 885 886 def _start_stop_button_press(self) -> None: 887 if ( 888 self._waiting_for_start_stop_response 889 or self._waiting_for_initial_state 890 ): 891 return 892 893 if ba.internal.get_v1_account_state() != 'signed_in': 894 ba.screenmessage(ba.Lstr(resource='notSignedInErrorText')) 895 ba.playsound(ba.getsound('error')) 896 self._refresh_sub_tab() 897 return 898 899 if self._hostingstate.unavailable_error is not None: 900 ba.playsound(ba.getsound('error')) 901 return 902 903 ba.playsound(ba.getsound('click01')) 904 905 # If we're not hosting, start. 906 if self._hostingstate.party_code is None: 907 908 # If there's a ticket cost, make sure we have enough tickets. 909 if self._hostingstate.tickets_to_host_now > 0: 910 ticket_count: int | None 911 try: 912 ticket_count = ba.internal.get_v1_account_ticket_count() 913 except Exception: 914 # FIXME: should add a ba.NotSignedInError we can use here. 915 ticket_count = None 916 ticket_cost = self._hostingstate.tickets_to_host_now 917 if ticket_count is not None and ticket_count < ticket_cost: 918 getcurrency.show_get_tickets_prompt() 919 ba.playsound(ba.getsound('error')) 920 return 921 self._last_action_send_time = time.time() 922 ba.internal.add_transaction( 923 { 924 'type': 'PRIVATE_PARTY_START', 925 'config': dataclass_to_dict(self._hostingconfig), 926 'region_pings': ba.app.net.zone_pings, 927 'expire_time': time.time() + 20, 928 }, 929 callback=ba.WeakCall(self._hosting_state_response), 930 ) 931 ba.internal.run_transactions() 932 933 else: 934 self._last_action_send_time = time.time() 935 ba.internal.add_transaction( 936 { 937 'type': 'PRIVATE_PARTY_STOP', 938 'expire_time': time.time() + 20, 939 }, 940 callback=ba.WeakCall(self._hosting_state_response), 941 ) 942 ba.internal.run_transactions() 943 ba.playsound(ba.getsound('click01')) 944 945 self._waiting_for_start_stop_response = True 946 self._refresh_sub_tab() 947 948 def _join_connect_press(self) -> None: 949 950 # Error immediately if its an empty code. 951 code: str | None = None 952 if self._join_party_code_text: 953 code = cast(str, ba.textwidget(query=self._join_party_code_text)) 954 if not code: 955 ba.screenmessage( 956 ba.Lstr(resource='internal.invalidAddressErrorText'), 957 color=(1, 0, 0), 958 ) 959 ba.playsound(ba.getsound('error')) 960 return 961 962 self._connect_to_party_code(code) 963 964 def _connect_response(self, result: dict[str, Any] | None) -> None: 965 try: 966 self._connect_press_time = None 967 if result is None: 968 raise RuntimeError() 969 cresult = dataclass_from_dict( 970 PrivatePartyConnectResult, result, discard_unknown_attrs=True 971 ) 972 if cresult.error is not None: 973 self._debug_server_comm('got error connect response') 974 ba.screenmessage( 975 ba.Lstr(translate=('serverResponses', cresult.error)), 976 (1, 0, 0), 977 ) 978 ba.playsound(ba.getsound('error')) 979 return 980 self._debug_server_comm('got valid connect response') 981 assert cresult.addr is not None and cresult.port is not None 982 ba.internal.connect_to_party(cresult.addr, port=cresult.port) 983 except Exception: 984 self._debug_server_comm('got connect response error') 985 ba.playsound(ba.getsound('error')) 986 987 def save_state(self) -> None: 988 ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state) 989 990 def restore_state(self) -> None: 991 state = ba.app.ui.window_states.get(type(self)) 992 if state is None: 993 state = State() 994 assert isinstance(state, State) 995 self._state = state
The private tab in the gather UI
PrivateGatherTab(window: bastd.ui.gather.GatherWindow)
51 def __init__(self, window: GatherWindow) -> None: 52 super().__init__(window) 53 self._container: ba.Widget | None = None 54 self._state: State = State() 55 self._hostingstate = PrivateHostingState() 56 self._join_sub_tab_text: ba.Widget | None = None 57 self._host_sub_tab_text: ba.Widget | None = None 58 self._update_timer: ba.Timer | None = None 59 self._join_party_code_text: ba.Widget | None = None 60 self._c_width: float = 0.0 61 self._c_height: float = 0.0 62 self._last_hosting_state_query_time: float | None = None 63 self._waiting_for_initial_state = True 64 self._waiting_for_start_stop_response = True 65 self._host_playlist_button: ba.Widget | None = None 66 self._host_copy_button: ba.Widget | None = None 67 self._host_connect_button: ba.Widget | None = None 68 self._host_start_stop_button: ba.Widget | None = None 69 self._get_tickets_button: ba.Widget | None = None 70 self._ticket_count_text: ba.Widget | None = None 71 self._showing_not_signed_in_screen = False 72 self._create_time = time.time() 73 self._last_action_send_time: float | None = None 74 self._connect_press_time: float | None = None 75 try: 76 self._hostingconfig = self._build_hosting_config() 77 except Exception: 78 ba.print_exception('Error building hosting config') 79 self._hostingconfig = PrivateHostingConfig()
def
on_activate( self, parent_widget: _ba.Widget, tab_button: _ba.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float) -> _ba.Widget:
81 def on_activate( 82 self, 83 parent_widget: ba.Widget, 84 tab_button: ba.Widget, 85 region_width: float, 86 region_height: float, 87 region_left: float, 88 region_bottom: float, 89 ) -> ba.Widget: 90 self._c_width = region_width 91 self._c_height = region_height - 20 92 self._container = ba.containerwidget( 93 parent=parent_widget, 94 position=( 95 region_left, 96 region_bottom + (region_height - self._c_height) * 0.5, 97 ), 98 size=(self._c_width, self._c_height), 99 background=False, 100 selection_loops_to_parent=True, 101 ) 102 v = self._c_height - 30.0 103 self._join_sub_tab_text = ba.textwidget( 104 parent=self._container, 105 position=(self._c_width * 0.5 - 245, v - 13), 106 color=(0.6, 1.0, 0.6), 107 scale=1.3, 108 size=(200, 30), 109 maxwidth=250, 110 h_align='left', 111 v_align='center', 112 click_activate=True, 113 selectable=True, 114 autoselect=True, 115 on_activate_call=lambda: self._set_sub_tab( 116 SubTabType.JOIN, 117 playsound=True, 118 ), 119 text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'), 120 ) 121 self._host_sub_tab_text = ba.textwidget( 122 parent=self._container, 123 position=(self._c_width * 0.5 + 45, v - 13), 124 color=(0.6, 1.0, 0.6), 125 scale=1.3, 126 size=(200, 30), 127 maxwidth=250, 128 h_align='left', 129 v_align='center', 130 click_activate=True, 131 selectable=True, 132 autoselect=True, 133 on_activate_call=lambda: self._set_sub_tab( 134 SubTabType.HOST, 135 playsound=True, 136 ), 137 text=ba.Lstr(resource='gatherWindow.privatePartyHostText'), 138 ) 139 ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button) 140 ba.widget( 141 edit=self._host_sub_tab_text, 142 left_widget=self._join_sub_tab_text, 143 up_widget=tab_button, 144 ) 145 ba.widget( 146 edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text 147 ) 148 149 self._update_timer = ba.Timer( 150 1.0, 151 ba.WeakCall(self._update), 152 repeat=True, 153 timetype=ba.TimeType.REAL, 154 ) 155 156 # Prevent taking any action until we've updated our state. 157 self._waiting_for_initial_state = True 158 159 # This will get a state query sent out immediately. 160 self._last_action_send_time = None # Ensure we don't ignore response. 161 self._last_hosting_state_query_time = None 162 self._update() 163 164 self._set_sub_tab(self._state.sub_tab) 165 166 return self._container
Called when the tab becomes the active one.
The tab should create and return a container widget covering the specified region.
def
save_state(self) -> None:
987 def save_state(self) -> None: 988 ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state)
Called when the parent window is saving state.
def
restore_state(self) -> None:
990 def restore_state(self) -> None: 991 state = ba.app.ui.window_states.get(type(self)) 992 if state is None: 993 state = State() 994 assert isinstance(state, State) 995 self._state = state
Called when the parent window is restoring state.