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