bastd.ui.gather.publictab
Defines the public tab in the gather UI.
1# Released under the MIT License. See LICENSE for details. 2# 3# pylint: disable=too-many-lines 4"""Defines the public tab in the gather UI.""" 5 6from __future__ import annotations 7 8import copy 9import time 10import threading 11from enum import Enum 12from dataclasses import dataclass 13from typing import TYPE_CHECKING, cast 14 15import ba 16import ba.internal 17from bastd.ui.gather import GatherTab 18 19if TYPE_CHECKING: 20 from typing import Callable, Any 21 from bastd.ui.gather import GatherWindow 22 23# Print a bit of info about pings, queries, etc. 24DEBUG_SERVER_COMMUNICATION = False 25DEBUG_PROCESSING = False 26 27 28class SubTabType(Enum): 29 """Available sub-tabs.""" 30 31 JOIN = 'join' 32 HOST = 'host' 33 34 35@dataclass 36class PartyEntry: 37 """Info about a public party.""" 38 39 address: str 40 index: int 41 queue: str | None = None 42 port: int = -1 43 name: str = '' 44 size: int = -1 45 size_max: int = -1 46 claimed: bool = False 47 ping: float | None = None 48 ping_interval: float = -1.0 49 next_ping_time: float = -1.0 50 ping_attempts: int = 0 51 ping_responses: int = 0 52 stats_addr: str | None = None 53 clean_display_index: int | None = None 54 55 def get_key(self) -> str: 56 """Return the key used to store this party.""" 57 return f'{self.address}_{self.port}' 58 59 60class UIRow: 61 """Wrangles UI for a row in the party list.""" 62 63 def __init__(self) -> None: 64 self._name_widget: ba.Widget | None = None 65 self._size_widget: ba.Widget | None = None 66 self._ping_widget: ba.Widget | None = None 67 self._stats_button: ba.Widget | None = None 68 69 def __del__(self) -> None: 70 self._clear() 71 72 def _clear(self) -> None: 73 for widget in [ 74 self._name_widget, 75 self._size_widget, 76 self._ping_widget, 77 self._stats_button, 78 ]: 79 if widget: 80 widget.delete() 81 82 def update( 83 self, 84 index: int, 85 party: PartyEntry, 86 sub_scroll_width: float, 87 sub_scroll_height: float, 88 lineheight: float, 89 columnwidget: ba.Widget, 90 join_text: ba.Widget, 91 filter_text: ba.Widget, 92 existing_selection: Selection | None, 93 tab: PublicGatherTab, 94 ) -> None: 95 """Update for the given data.""" 96 # pylint: disable=too-many-locals 97 98 # Quick-out: if we've been marked clean for a certain index and 99 # we're still at that index, we're done. 100 if party.clean_display_index == index: 101 return 102 103 ping_good = ba.internal.get_v1_account_misc_read_val('pingGood', 100) 104 ping_med = ba.internal.get_v1_account_misc_read_val('pingMed', 500) 105 106 self._clear() 107 hpos = 20 108 vpos = sub_scroll_height - lineheight * index - 50 109 self._name_widget = ba.textwidget( 110 text=ba.Lstr(value=party.name), 111 parent=columnwidget, 112 size=(sub_scroll_width * 0.63, 20), 113 position=(0 + hpos, 4 + vpos), 114 selectable=True, 115 on_select_call=ba.WeakCall( 116 tab.set_public_party_selection, 117 Selection(party.get_key(), SelectionComponent.NAME), 118 ), 119 on_activate_call=ba.WeakCall(tab.on_public_party_activate, party), 120 click_activate=True, 121 maxwidth=sub_scroll_width * 0.45, 122 corner_scale=1.4, 123 autoselect=True, 124 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 125 h_align='left', 126 v_align='center', 127 ) 128 ba.widget( 129 edit=self._name_widget, 130 left_widget=join_text, 131 show_buffer_top=64.0, 132 show_buffer_bottom=64.0, 133 ) 134 if existing_selection == Selection( 135 party.get_key(), SelectionComponent.NAME 136 ): 137 ba.containerwidget( 138 edit=columnwidget, selected_child=self._name_widget 139 ) 140 if party.stats_addr: 141 url = party.stats_addr.replace( 142 '${ACCOUNT}', 143 ba.internal.get_v1_account_misc_read_val_2( 144 'resolvedAccountID', 'UNKNOWN' 145 ), 146 ) 147 self._stats_button = ba.buttonwidget( 148 color=(0.3, 0.6, 0.94), 149 textcolor=(1.0, 1.0, 1.0), 150 label=ba.Lstr(resource='statsText'), 151 parent=columnwidget, 152 autoselect=True, 153 on_activate_call=ba.Call(ba.open_url, url), 154 on_select_call=ba.WeakCall( 155 tab.set_public_party_selection, 156 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 157 ), 158 size=(120, 40), 159 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 160 scale=0.9, 161 ) 162 if existing_selection == Selection( 163 party.get_key(), SelectionComponent.STATS_BUTTON 164 ): 165 ba.containerwidget( 166 edit=columnwidget, selected_child=self._stats_button 167 ) 168 169 self._size_widget = ba.textwidget( 170 text=str(party.size) + '/' + str(party.size_max), 171 parent=columnwidget, 172 size=(0, 0), 173 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 174 scale=0.7, 175 color=(0.8, 0.8, 0.8), 176 h_align='right', 177 v_align='center', 178 ) 179 180 if index == 0: 181 ba.widget(edit=self._name_widget, up_widget=filter_text) 182 if self._stats_button: 183 ba.widget(edit=self._stats_button, up_widget=filter_text) 184 185 self._ping_widget = ba.textwidget( 186 parent=columnwidget, 187 size=(0, 0), 188 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 189 scale=0.7, 190 h_align='right', 191 v_align='center', 192 ) 193 if party.ping is None: 194 ba.textwidget( 195 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 196 ) 197 else: 198 ba.textwidget( 199 edit=self._ping_widget, 200 text=str(int(party.ping)), 201 color=(0, 1, 0) 202 if party.ping <= ping_good 203 else (1, 1, 0) 204 if party.ping <= ping_med 205 else (1, 0, 0), 206 ) 207 208 party.clean_display_index = index 209 210 211@dataclass 212class State: 213 """State saved/restored only while the app is running.""" 214 215 sub_tab: SubTabType = SubTabType.JOIN 216 parties: list[tuple[str, PartyEntry]] | None = None 217 next_entry_index: int = 0 218 filter_value: str = '' 219 have_server_list_response: bool = False 220 have_valid_server_list: bool = False 221 222 223class SelectionComponent(Enum): 224 """Describes what part of an entry is selected.""" 225 226 NAME = 'name' 227 STATS_BUTTON = 'stats_button' 228 229 230@dataclass 231class Selection: 232 """Describes the currently selected list element.""" 233 234 entry_key: str 235 component: SelectionComponent 236 237 238class AddrFetchThread(threading.Thread): 239 """Thread for fetching an address in the bg.""" 240 241 def __init__(self, call: Callable[[Any], Any]): 242 super().__init__() 243 self._call = call 244 245 def run(self) -> None: 246 try: 247 # FIXME: Update this to work with IPv6 at some point. 248 import socket 249 250 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 251 sock.connect(('8.8.8.8', 80)) 252 val = sock.getsockname()[0] 253 sock.close() 254 ba.pushcall(ba.Call(self._call, val), from_other_thread=True) 255 except Exception as exc: 256 from efro.error import is_udp_communication_error 257 258 # Ignore expected network errors; log others. 259 if is_udp_communication_error(exc): 260 pass 261 else: 262 ba.print_exception() 263 264 265class PingThread(threading.Thread): 266 """Thread for sending out game pings.""" 267 268 def __init__( 269 self, 270 address: str, 271 port: int, 272 call: Callable[[str, int, float | None], int | None], 273 ): 274 super().__init__() 275 self._address = address 276 self._port = port 277 self._call = call 278 279 def run(self) -> None: 280 ba.app.ping_thread_count += 1 281 sock: socket.socket | None = None 282 try: 283 import socket 284 from ba.internal import get_ip_address_type 285 286 socket_type = get_ip_address_type(self._address) 287 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 288 sock.connect((self._address, self._port)) 289 290 accessible = False 291 starttime = time.time() 292 293 # Send a few pings and wait a second for 294 # a response. 295 sock.settimeout(1) 296 for _i in range(3): 297 sock.send(b'\x0b') 298 result: bytes | None 299 try: 300 # 11: BA_PACKET_SIMPLE_PING 301 result = sock.recv(10) 302 except Exception: 303 result = None 304 if result == b'\x0c': 305 # 12: BA_PACKET_SIMPLE_PONG 306 accessible = True 307 break 308 time.sleep(1) 309 ping = (time.time() - starttime) * 1000.0 310 ba.pushcall( 311 ba.Call( 312 self._call, 313 self._address, 314 self._port, 315 ping if accessible else None, 316 ), 317 from_other_thread=True, 318 ) 319 except Exception as exc: 320 from efro.error import is_udp_communication_error 321 322 if is_udp_communication_error(exc): 323 pass 324 else: 325 ba.print_exception('Error on gather ping', once=True) 326 finally: 327 try: 328 if sock is not None: 329 sock.close() 330 except Exception: 331 ba.print_exception('Error on gather ping cleanup', once=True) 332 333 ba.app.ping_thread_count -= 1 334 335 336class PublicGatherTab(GatherTab): 337 """The public tab in the gather UI""" 338 339 def __init__(self, window: GatherWindow) -> None: 340 super().__init__(window) 341 self._container: ba.Widget | None = None 342 self._join_text: ba.Widget | None = None 343 self._host_text: ba.Widget | None = None 344 self._filter_text: ba.Widget | None = None 345 self._local_address: str | None = None 346 self._last_connect_attempt_time: float | None = None 347 self._sub_tab: SubTabType = SubTabType.JOIN 348 self._selection: Selection | None = None 349 self._refreshing_list = False 350 self._update_timer: ba.Timer | None = None 351 self._host_scrollwidget: ba.Widget | None = None 352 self._host_name_text: ba.Widget | None = None 353 self._host_toggle_button: ba.Widget | None = None 354 self._last_server_list_query_time: float | None = None 355 self._join_list_column: ba.Widget | None = None 356 self._join_status_text: ba.Widget | None = None 357 self._host_max_party_size_value: ba.Widget | None = None 358 self._host_max_party_size_minus_button: (ba.Widget | None) = None 359 self._host_max_party_size_plus_button: (ba.Widget | None) = None 360 self._host_status_text: ba.Widget | None = None 361 self._signed_in = False 362 self._ui_rows: list[UIRow] = [] 363 self._refresh_ui_row = 0 364 self._have_user_selected_row = False 365 self._first_valid_server_list_time: float | None = None 366 367 # Parties indexed by id: 368 self._parties: dict[str, PartyEntry] = {} 369 370 # Parties sorted in display order: 371 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 372 self._party_lists_dirty = True 373 374 # Sorted parties with filter applied: 375 self._parties_displayed: dict[str, PartyEntry] = {} 376 377 self._next_entry_index = 0 378 self._have_server_list_response = False 379 self._have_valid_server_list = False 380 self._filter_value = '' 381 self._pending_party_infos: list[dict[str, Any]] = [] 382 self._last_sub_scroll_height = 0.0 383 384 def on_activate( 385 self, 386 parent_widget: ba.Widget, 387 tab_button: ba.Widget, 388 region_width: float, 389 region_height: float, 390 region_left: float, 391 region_bottom: float, 392 ) -> ba.Widget: 393 c_width = region_width 394 c_height = region_height - 20 395 self._container = ba.containerwidget( 396 parent=parent_widget, 397 position=( 398 region_left, 399 region_bottom + (region_height - c_height) * 0.5, 400 ), 401 size=(c_width, c_height), 402 background=False, 403 selection_loops_to_parent=True, 404 ) 405 v = c_height - 30 406 self._join_text = ba.textwidget( 407 parent=self._container, 408 position=(c_width * 0.5 - 245, v - 13), 409 color=(0.6, 1.0, 0.6), 410 scale=1.3, 411 size=(200, 30), 412 maxwidth=250, 413 h_align='left', 414 v_align='center', 415 click_activate=True, 416 selectable=True, 417 autoselect=True, 418 on_activate_call=lambda: self._set_sub_tab( 419 SubTabType.JOIN, 420 region_width, 421 region_height, 422 playsound=True, 423 ), 424 text=ba.Lstr( 425 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 426 ), 427 ) 428 self._host_text = ba.textwidget( 429 parent=self._container, 430 position=(c_width * 0.5 + 45, v - 13), 431 color=(0.6, 1.0, 0.6), 432 scale=1.3, 433 size=(200, 30), 434 maxwidth=250, 435 h_align='left', 436 v_align='center', 437 click_activate=True, 438 selectable=True, 439 autoselect=True, 440 on_activate_call=lambda: self._set_sub_tab( 441 SubTabType.HOST, 442 region_width, 443 region_height, 444 playsound=True, 445 ), 446 text=ba.Lstr( 447 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 448 ), 449 ) 450 ba.widget(edit=self._join_text, up_widget=tab_button) 451 ba.widget( 452 edit=self._host_text, 453 left_widget=self._join_text, 454 up_widget=tab_button, 455 ) 456 ba.widget(edit=self._join_text, right_widget=self._host_text) 457 458 # Attempt to fetch our local address so we have it for error messages. 459 if self._local_address is None: 460 AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start() 461 462 self._set_sub_tab(self._sub_tab, region_width, region_height) 463 self._update_timer = ba.Timer( 464 0.1, 465 ba.WeakCall(self._update), 466 repeat=True, 467 timetype=ba.TimeType.REAL, 468 ) 469 return self._container 470 471 def on_deactivate(self) -> None: 472 self._update_timer = None 473 474 def save_state(self) -> None: 475 476 # Save off a small number of parties with the lowest ping; we'll 477 # display these immediately when our UI comes back up which should 478 # be enough to make things feel nice and crisp while we do a full 479 # server re-query or whatnot. 480 ba.app.ui.window_states[type(self)] = State( 481 sub_tab=self._sub_tab, 482 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 483 next_entry_index=self._next_entry_index, 484 filter_value=self._filter_value, 485 have_server_list_response=self._have_server_list_response, 486 have_valid_server_list=self._have_valid_server_list, 487 ) 488 489 def restore_state(self) -> None: 490 state = ba.app.ui.window_states.get(type(self)) 491 if state is None: 492 state = State() 493 assert isinstance(state, State) 494 self._sub_tab = state.sub_tab 495 496 # Restore the parties we stored. 497 if state.parties: 498 self._parties = { 499 key: copy.copy(party) for key, party in state.parties 500 } 501 self._parties_sorted = list(self._parties.items()) 502 self._party_lists_dirty = True 503 504 self._next_entry_index = state.next_entry_index 505 506 # FIXME: should save/restore these too?.. 507 self._have_server_list_response = state.have_server_list_response 508 self._have_valid_server_list = state.have_valid_server_list 509 self._filter_value = state.filter_value 510 511 def _set_sub_tab( 512 self, 513 value: SubTabType, 514 region_width: float, 515 region_height: float, 516 playsound: bool = False, 517 ) -> None: 518 assert self._container 519 if playsound: 520 ba.playsound(ba.getsound('click01')) 521 522 # Reset our selection. 523 # (prevents selecting something way down the list if we switched away 524 # and came back) 525 self._selection = None 526 self._have_user_selected_row = False 527 528 # Reset refresh to the top and make sure everything refreshes. 529 self._refresh_ui_row = 0 530 for party in self._parties.values(): 531 party.clean_display_index = None 532 533 self._sub_tab = value 534 active_color = (0.6, 1.0, 0.6) 535 inactive_color = (0.5, 0.4, 0.5) 536 ba.textwidget( 537 edit=self._join_text, 538 color=active_color if value is SubTabType.JOIN else inactive_color, 539 ) 540 ba.textwidget( 541 edit=self._host_text, 542 color=active_color if value is SubTabType.HOST else inactive_color, 543 ) 544 545 # Clear anything existing in the old sub-tab. 546 for widget in self._container.get_children(): 547 if widget and widget not in {self._host_text, self._join_text}: 548 widget.delete() 549 550 if value is SubTabType.JOIN: 551 self._build_join_tab(region_width, region_height) 552 553 if value is SubTabType.HOST: 554 self._build_host_tab(region_width, region_height) 555 556 def _build_join_tab( 557 self, region_width: float, region_height: float 558 ) -> None: 559 c_width = region_width 560 c_height = region_height - 20 561 sub_scroll_height = c_height - 125 562 sub_scroll_width = 830 563 v = c_height - 35 564 v -= 60 565 filter_txt = ba.Lstr(resource='filterText') 566 self._filter_text = ba.textwidget( 567 parent=self._container, 568 text=self._filter_value, 569 size=(350, 45), 570 position=(290, v - 10), 571 h_align='left', 572 v_align='center', 573 editable=True, 574 maxwidth=310, 575 description=filter_txt, 576 ) 577 ba.widget(edit=self._filter_text, up_widget=self._join_text) 578 ba.textwidget( 579 text=filter_txt, 580 parent=self._container, 581 size=(0, 0), 582 position=(270, v + 13), 583 maxwidth=150, 584 scale=0.8, 585 color=(0.5, 0.46, 0.5), 586 flatness=1.0, 587 h_align='right', 588 v_align='center', 589 ) 590 591 ba.textwidget( 592 text=ba.Lstr(resource='nameText'), 593 parent=self._container, 594 size=(0, 0), 595 position=(90, v - 8), 596 maxwidth=60, 597 scale=0.6, 598 color=(0.5, 0.46, 0.5), 599 flatness=1.0, 600 h_align='center', 601 v_align='center', 602 ) 603 ba.textwidget( 604 text=ba.Lstr(resource='gatherWindow.partySizeText'), 605 parent=self._container, 606 size=(0, 0), 607 position=(755, v - 8), 608 maxwidth=60, 609 scale=0.6, 610 color=(0.5, 0.46, 0.5), 611 flatness=1.0, 612 h_align='center', 613 v_align='center', 614 ) 615 ba.textwidget( 616 text=ba.Lstr(resource='gatherWindow.pingText'), 617 parent=self._container, 618 size=(0, 0), 619 position=(825, v - 8), 620 maxwidth=60, 621 scale=0.6, 622 color=(0.5, 0.46, 0.5), 623 flatness=1.0, 624 h_align='center', 625 v_align='center', 626 ) 627 v -= sub_scroll_height + 23 628 self._host_scrollwidget = scrollw = ba.scrollwidget( 629 parent=self._container, 630 simple_culling_v=10, 631 position=((c_width - sub_scroll_width) * 0.5, v), 632 size=(sub_scroll_width, sub_scroll_height), 633 claims_up_down=False, 634 claims_left_right=True, 635 autoselect=True, 636 ) 637 self._join_list_column = ba.containerwidget( 638 parent=scrollw, 639 background=False, 640 size=(400, 400), 641 claims_left_right=True, 642 ) 643 self._join_status_text = ba.textwidget( 644 parent=self._container, 645 text='', 646 size=(0, 0), 647 scale=0.9, 648 flatness=1.0, 649 shadow=0.0, 650 h_align='center', 651 v_align='top', 652 maxwidth=c_width, 653 color=(0.6, 0.6, 0.6), 654 position=(c_width * 0.5, c_height * 0.5), 655 ) 656 657 def _build_host_tab( 658 self, region_width: float, region_height: float 659 ) -> None: 660 c_width = region_width 661 c_height = region_height - 20 662 v = c_height - 35 663 v -= 25 664 is_public_enabled = ba.internal.get_public_party_enabled() 665 v -= 30 666 667 ba.textwidget( 668 parent=self._container, 669 size=(0, 0), 670 h_align='center', 671 v_align='center', 672 maxwidth=c_width * 0.9, 673 scale=0.7, 674 flatness=1.0, 675 color=(0.5, 0.46, 0.5), 676 position=(region_width * 0.5, v + 10), 677 text=ba.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 678 ) 679 v -= 30 680 681 party_name_text = ba.Lstr( 682 resource='gatherWindow.partyNameText', 683 fallback_resource='editGameListWindow.nameText', 684 ) 685 ba.textwidget( 686 parent=self._container, 687 size=(0, 0), 688 h_align='right', 689 v_align='center', 690 maxwidth=200, 691 scale=0.8, 692 color=ba.app.ui.infotextcolor, 693 position=(210, v - 9), 694 text=party_name_text, 695 ) 696 self._host_name_text = ba.textwidget( 697 parent=self._container, 698 editable=True, 699 size=(535, 40), 700 position=(230, v - 30), 701 text=ba.app.config.get('Public Party Name', ''), 702 maxwidth=494, 703 shadow=0.3, 704 flatness=1.0, 705 description=party_name_text, 706 autoselect=True, 707 v_align='center', 708 corner_scale=1.0, 709 ) 710 711 v -= 60 712 ba.textwidget( 713 parent=self._container, 714 size=(0, 0), 715 h_align='right', 716 v_align='center', 717 maxwidth=200, 718 scale=0.8, 719 color=ba.app.ui.infotextcolor, 720 position=(210, v - 9), 721 text=ba.Lstr( 722 resource='maxPartySizeText', 723 fallback_resource='maxConnectionsText', 724 ), 725 ) 726 self._host_max_party_size_value = ba.textwidget( 727 parent=self._container, 728 size=(0, 0), 729 h_align='center', 730 v_align='center', 731 scale=1.2, 732 color=(1, 1, 1), 733 position=(240, v - 9), 734 text=str(ba.internal.get_public_party_max_size()), 735 ) 736 btn1 = self._host_max_party_size_minus_button = ba.buttonwidget( 737 parent=self._container, 738 size=(40, 40), 739 on_activate_call=ba.WeakCall( 740 self._on_max_public_party_size_minus_press 741 ), 742 position=(280, v - 26), 743 label='-', 744 autoselect=True, 745 ) 746 btn2 = self._host_max_party_size_plus_button = ba.buttonwidget( 747 parent=self._container, 748 size=(40, 40), 749 on_activate_call=ba.WeakCall( 750 self._on_max_public_party_size_plus_press 751 ), 752 position=(350, v - 26), 753 label='+', 754 autoselect=True, 755 ) 756 v -= 50 757 v -= 70 758 if is_public_enabled: 759 label = ba.Lstr( 760 resource='gatherWindow.makePartyPrivateText', 761 fallback_resource='gatherWindow.stopAdvertisingText', 762 ) 763 else: 764 label = ba.Lstr( 765 resource='gatherWindow.makePartyPublicText', 766 fallback_resource='gatherWindow.startAdvertisingText', 767 ) 768 self._host_toggle_button = ba.buttonwidget( 769 parent=self._container, 770 label=label, 771 size=(400, 80), 772 on_activate_call=self._on_stop_advertising_press 773 if is_public_enabled 774 else self._on_start_advertizing_press, 775 position=(c_width * 0.5 - 200, v), 776 autoselect=True, 777 up_widget=btn2, 778 ) 779 ba.widget(edit=self._host_name_text, down_widget=btn2) 780 ba.widget(edit=btn2, up_widget=self._host_name_text) 781 ba.widget(edit=btn1, up_widget=self._host_name_text) 782 ba.widget(edit=self._join_text, down_widget=self._host_name_text) 783 v -= 10 784 self._host_status_text = ba.textwidget( 785 parent=self._container, 786 text=ba.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 787 size=(0, 0), 788 scale=0.7, 789 flatness=1.0, 790 h_align='center', 791 v_align='top', 792 maxwidth=c_width * 0.9, 793 color=(0.6, 0.56, 0.6), 794 position=(c_width * 0.5, v), 795 ) 796 v -= 90 797 ba.textwidget( 798 parent=self._container, 799 text=ba.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 800 size=(0, 0), 801 scale=0.7, 802 flatness=1.0, 803 h_align='center', 804 v_align='center', 805 maxwidth=c_width * 0.9, 806 color=(0.5, 0.46, 0.5), 807 position=(c_width * 0.5, v), 808 ) 809 810 # If public sharing is already on, 811 # launch a status-check immediately. 812 if ba.internal.get_public_party_enabled(): 813 self._do_status_check() 814 815 def _on_public_party_query_result( 816 self, result: dict[str, Any] | None 817 ) -> None: 818 starttime = time.time() 819 self._have_server_list_response = True 820 821 if result is None: 822 self._have_valid_server_list = False 823 return 824 825 if not self._have_valid_server_list: 826 self._first_valid_server_list_time = time.time() 827 828 self._have_valid_server_list = True 829 parties_in = result['l'] 830 831 assert isinstance(parties_in, list) 832 self._pending_party_infos += parties_in 833 834 # To avoid causing a stutter here, we do most processing of 835 # these entries incrementally in our _update() method. 836 # The one thing we do here is prune parties not contained in 837 # this result. 838 for partyval in list(self._parties.values()): 839 partyval.claimed = False 840 for party_in in parties_in: 841 addr = party_in['a'] 842 assert isinstance(addr, str) 843 port = party_in['p'] 844 assert isinstance(port, int) 845 party_key = f'{addr}_{port}' 846 party = self._parties.get(party_key) 847 if party is not None: 848 party.claimed = True 849 self._parties = { 850 key: val for key, val in list(self._parties.items()) if val.claimed 851 } 852 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 853 self._party_lists_dirty = True 854 855 # self._update_server_list() 856 if DEBUG_PROCESSING: 857 print( 858 f'Handled public party query results in ' 859 f'{time.time()-starttime:.5f}s.' 860 ) 861 862 def _update(self) -> None: 863 """Periodic updating.""" 864 865 # Special case: if a party-queue window is up, don't do any of this 866 # (keeps things smoother). 867 # if ba.app.ui.have_party_queue_window: 868 # return 869 870 if self._sub_tab is SubTabType.JOIN: 871 872 # Keep our filter-text up to date from the UI. 873 text = self._filter_text 874 if text: 875 filter_value = cast(str, ba.textwidget(query=text)) 876 if filter_value != self._filter_value: 877 self._filter_value = filter_value 878 self._party_lists_dirty = True 879 880 # Also wipe out party clean-row states. 881 # (otherwise if a party disappears from a row due to 882 # filtering and then reappears on that same row when 883 # the filter is removed it may not update) 884 for party in self._parties.values(): 885 party.clean_display_index = None 886 887 self._query_party_list_periodically() 888 self._ping_parties_periodically() 889 890 # If any new party infos have come in, apply some of them. 891 self._process_pending_party_infos() 892 893 # Anytime we sign in/out, make sure we refresh our list. 894 signed_in = ba.internal.get_v1_account_state() == 'signed_in' 895 if self._signed_in != signed_in: 896 self._signed_in = signed_in 897 self._party_lists_dirty = True 898 899 # Update sorting to account for ping updates, new parties, etc. 900 self._update_party_lists() 901 902 # If we've got a party-name text widget, keep its value plugged 903 # into our public host name. 904 text = self._host_name_text 905 if text: 906 name = cast(str, ba.textwidget(query=self._host_name_text)) 907 ba.internal.set_public_party_name(name) 908 909 # Update status text. 910 status_text = self._join_status_text 911 if status_text: 912 if not signed_in: 913 ba.textwidget( 914 edit=status_text, text=ba.Lstr(resource='notSignedInText') 915 ) 916 else: 917 # If we have a valid list, show no status; just the list. 918 # Otherwise show either 'loading...' or 'error' depending 919 # on whether this is our first go-round. 920 if self._have_valid_server_list: 921 ba.textwidget(edit=status_text, text='') 922 else: 923 if self._have_server_list_response: 924 ba.textwidget( 925 edit=status_text, text=ba.Lstr(resource='errorText') 926 ) 927 else: 928 ba.textwidget( 929 edit=status_text, 930 text=ba.Lstr( 931 value='${A}...', 932 subs=[ 933 ( 934 '${A}', 935 ba.Lstr(resource='store.loadingText'), 936 ) 937 ], 938 ), 939 ) 940 941 self._update_party_rows() 942 943 def _update_party_rows(self) -> None: 944 columnwidget = self._join_list_column 945 if not columnwidget: 946 return 947 948 assert self._join_text 949 assert self._filter_text 950 951 # Janky - allow escaping when there's nothing in our list. 952 assert self._host_scrollwidget 953 ba.containerwidget( 954 edit=self._host_scrollwidget, 955 claims_up_down=(len(self._parties_displayed) > 0), 956 ) 957 958 # Clip if we have more UI rows than parties to show. 959 clipcount = len(self._ui_rows) - len(self._parties_displayed) 960 if clipcount > 0: 961 clipcount = max(clipcount, 50) 962 self._ui_rows = self._ui_rows[:-clipcount] 963 964 # If we have no parties to show, we're done. 965 if not self._parties_displayed: 966 return 967 968 sub_scroll_width = 830 969 lineheight = 42 970 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 971 ba.containerwidget( 972 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 973 ) 974 975 # Any time our height changes, reset the refresh back to the top 976 # so we don't see ugly empty spaces appearing during initial list 977 # filling. 978 if sub_scroll_height != self._last_sub_scroll_height: 979 self._refresh_ui_row = 0 980 self._last_sub_scroll_height = sub_scroll_height 981 982 # Also note that we need to redisplay everything since its pos 983 # will have changed.. :( 984 for party in self._parties.values(): 985 party.clean_display_index = None 986 987 # Ew; this rebuilding generates deferred selection callbacks 988 # so we need to push deferred notices so we know to ignore them. 989 def refresh_on() -> None: 990 self._refreshing_list = True 991 992 ba.pushcall(refresh_on) 993 994 # Ok, now here's the deal: we want to avoid creating/updating this 995 # entire list at one time because it will lead to hitches. So we 996 # refresh individual rows quickly in a loop. 997 rowcount = min(12, len(self._parties_displayed)) 998 999 party_vals_displayed = list(self._parties_displayed.values()) 1000 while rowcount > 0: 1001 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1002 if refresh_row >= len(self._ui_rows): 1003 self._ui_rows.append(UIRow()) 1004 refresh_row = len(self._ui_rows) - 1 1005 1006 # For the first few seconds after getting our first server-list, 1007 # refresh only the top section of the list; this allows the lowest 1008 # ping servers to show up more quickly. 1009 if self._first_valid_server_list_time is not None: 1010 if time.time() - self._first_valid_server_list_time < 4.0: 1011 if refresh_row > 40: 1012 refresh_row = 0 1013 1014 self._ui_rows[refresh_row].update( 1015 refresh_row, 1016 party_vals_displayed[refresh_row], 1017 sub_scroll_width=sub_scroll_width, 1018 sub_scroll_height=sub_scroll_height, 1019 lineheight=lineheight, 1020 columnwidget=columnwidget, 1021 join_text=self._join_text, 1022 existing_selection=self._selection, 1023 filter_text=self._filter_text, 1024 tab=self, 1025 ) 1026 self._refresh_ui_row = refresh_row + 1 1027 rowcount -= 1 1028 1029 # So our selection callbacks can start firing.. 1030 def refresh_off() -> None: 1031 self._refreshing_list = False 1032 1033 ba.pushcall(refresh_off) 1034 1035 def _process_pending_party_infos(self) -> None: 1036 starttime = time.time() 1037 1038 # We want to do this in small enough pieces to not cause UI hitches. 1039 chunksize = 30 1040 parties_in = self._pending_party_infos[:chunksize] 1041 self._pending_party_infos = self._pending_party_infos[chunksize:] 1042 for party_in in parties_in: 1043 addr = party_in['a'] 1044 assert isinstance(addr, str) 1045 port = party_in['p'] 1046 assert isinstance(port, int) 1047 party_key = f'{addr}_{port}' 1048 party = self._parties.get(party_key) 1049 if party is None: 1050 # If this party is new to us, init it. 1051 party = PartyEntry( 1052 address=addr, 1053 next_ping_time=ba.time(ba.TimeType.REAL) 1054 + 0.001 * party_in['pd'], 1055 index=self._next_entry_index, 1056 ) 1057 self._parties[party_key] = party 1058 self._parties_sorted.append((party_key, party)) 1059 self._party_lists_dirty = True 1060 self._next_entry_index += 1 1061 assert isinstance(party.address, str) 1062 assert isinstance(party.next_ping_time, float) 1063 1064 # Now, new or not, update its values. 1065 party.queue = party_in.get('q') 1066 assert isinstance(party.queue, (str, type(None))) 1067 party.port = port 1068 party.name = party_in['n'] 1069 assert isinstance(party.name, str) 1070 party.size = party_in['s'] 1071 assert isinstance(party.size, int) 1072 party.size_max = party_in['sm'] 1073 assert isinstance(party.size_max, int) 1074 1075 # Server provides this in milliseconds; we use seconds. 1076 party.ping_interval = 0.001 * party_in['pi'] 1077 assert isinstance(party.ping_interval, float) 1078 party.stats_addr = party_in['sa'] 1079 assert isinstance(party.stats_addr, (str, type(None))) 1080 1081 # Make sure the party's UI gets updated. 1082 party.clean_display_index = None 1083 1084 if DEBUG_PROCESSING and parties_in: 1085 print( 1086 f'Processed {len(parties_in)} raw party infos in' 1087 f' {time.time()-starttime:.5f}s.' 1088 ) 1089 1090 def _update_party_lists(self) -> None: 1091 if not self._party_lists_dirty: 1092 return 1093 starttime = time.time() 1094 assert len(self._parties_sorted) == len(self._parties) 1095 1096 self._parties_sorted.sort( 1097 key=lambda p: ( 1098 p[1].ping if p[1].ping is not None else 999999.0, 1099 p[1].index, 1100 ) 1101 ) 1102 1103 # If signed out or errored, show no parties. 1104 if ( 1105 ba.internal.get_v1_account_state() != 'signed_in' 1106 or not self._have_valid_server_list 1107 ): 1108 self._parties_displayed = {} 1109 else: 1110 if self._filter_value: 1111 filterval = self._filter_value.lower() 1112 self._parties_displayed = { 1113 k: v 1114 for k, v in self._parties_sorted 1115 if filterval in v.name.lower() 1116 } 1117 else: 1118 self._parties_displayed = dict(self._parties_sorted) 1119 1120 # Any time our selection disappears from the displayed list, go back to 1121 # auto-selecting the top entry. 1122 if ( 1123 self._selection is not None 1124 and self._selection.entry_key not in self._parties_displayed 1125 ): 1126 self._have_user_selected_row = False 1127 1128 # Whenever the user hasn't selected something, keep the first visible 1129 # row selected. 1130 if not self._have_user_selected_row and self._parties_displayed: 1131 firstpartykey = next(iter(self._parties_displayed)) 1132 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1133 1134 self._party_lists_dirty = False 1135 if DEBUG_PROCESSING: 1136 print( 1137 f'Sorted {len(self._parties_sorted)} parties in' 1138 f' {time.time()-starttime:.5f}s.' 1139 ) 1140 1141 def _query_party_list_periodically(self) -> None: 1142 now = ba.time(ba.TimeType.REAL) 1143 1144 # Fire off a new public-party query periodically. 1145 if ( 1146 self._last_server_list_query_time is None 1147 or now - self._last_server_list_query_time 1148 > 0.001 1149 * ba.internal.get_v1_account_misc_read_val( 1150 'pubPartyRefreshMS', 10000 1151 ) 1152 ): 1153 self._last_server_list_query_time = now 1154 if DEBUG_SERVER_COMMUNICATION: 1155 print('REQUESTING SERVER LIST') 1156 if ba.internal.get_v1_account_state() == 'signed_in': 1157 ba.internal.add_transaction( 1158 { 1159 'type': 'PUBLIC_PARTY_QUERY', 1160 'proto': ba.app.protocol_version, 1161 'lang': ba.app.lang.language, 1162 }, 1163 callback=ba.WeakCall(self._on_public_party_query_result), 1164 ) 1165 ba.internal.run_transactions() 1166 else: 1167 self._on_public_party_query_result(None) 1168 1169 def _ping_parties_periodically(self) -> None: 1170 now = ba.time(ba.TimeType.REAL) 1171 1172 # Go through our existing public party entries firing off pings 1173 # for any that have timed out. 1174 for party in list(self._parties.values()): 1175 if party.next_ping_time <= now and ba.app.ping_thread_count < 15: 1176 1177 # Crank the interval up for high-latency or non-responding 1178 # parties to save us some useless work. 1179 mult = 1 1180 if party.ping_responses == 0: 1181 if party.ping_attempts > 4: 1182 mult = 10 1183 elif party.ping_attempts > 2: 1184 mult = 5 1185 if party.ping is not None: 1186 mult = ( 1187 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1188 ) 1189 1190 interval = party.ping_interval * mult 1191 if DEBUG_SERVER_COMMUNICATION: 1192 print( 1193 f'pinging #{party.index} cur={party.ping} ' 1194 f'interval={interval} ' 1195 f'({party.ping_responses}/{party.ping_attempts})' 1196 ) 1197 1198 party.next_ping_time = now + party.ping_interval * mult 1199 party.ping_attempts += 1 1200 1201 PingThread( 1202 party.address, party.port, ba.WeakCall(self._ping_callback) 1203 ).start() 1204 1205 def _ping_callback( 1206 self, address: str, port: int | None, result: float | None 1207 ) -> None: 1208 # Look for a widget corresponding to this target. 1209 # If we find one, update our list. 1210 party_key = f'{address}_{port}' 1211 party = self._parties.get(party_key) 1212 if party is not None: 1213 if result is not None: 1214 party.ping_responses += 1 1215 1216 # We now smooth ping a bit to reduce jumping around in the list 1217 # (only where pings are relatively good). 1218 current_ping = party.ping 1219 if current_ping is not None and result is not None and result < 150: 1220 smoothing = 0.7 1221 party.ping = ( 1222 smoothing * current_ping + (1.0 - smoothing) * result 1223 ) 1224 else: 1225 party.ping = result 1226 1227 # Need to re-sort the list and update the row display. 1228 party.clean_display_index = None 1229 self._party_lists_dirty = True 1230 1231 def _fetch_local_addr_cb(self, val: str) -> None: 1232 self._local_address = str(val) 1233 1234 def _on_public_party_accessible_response( 1235 self, data: dict[str, Any] | None 1236 ) -> None: 1237 1238 # If we've got status text widgets, update them. 1239 text = self._host_status_text 1240 if text: 1241 if data is None: 1242 ba.textwidget( 1243 edit=text, 1244 text=ba.Lstr( 1245 resource='gatherWindow.' 'partyStatusNoConnectionText' 1246 ), 1247 color=(1, 0, 0), 1248 ) 1249 else: 1250 if not data.get('accessible', False): 1251 ex_line: str | ba.Lstr 1252 if self._local_address is not None: 1253 ex_line = ba.Lstr( 1254 value='\n${A} ${B}', 1255 subs=[ 1256 ( 1257 '${A}', 1258 ba.Lstr( 1259 resource='gatherWindow.' 1260 'manualYourLocalAddressText' 1261 ), 1262 ), 1263 ('${B}', self._local_address), 1264 ], 1265 ) 1266 else: 1267 ex_line = '' 1268 ba.textwidget( 1269 edit=text, 1270 text=ba.Lstr( 1271 value='${A}\n${B}${C}', 1272 subs=[ 1273 ( 1274 '${A}', 1275 ba.Lstr( 1276 resource='gatherWindow.' 1277 'partyStatusNotJoinableText' 1278 ), 1279 ), 1280 ( 1281 '${B}', 1282 ba.Lstr( 1283 resource='gatherWindow.' 1284 'manualRouterForwardingText', 1285 subs=[ 1286 ( 1287 '${PORT}', 1288 str( 1289 ba.internal.get_game_port() 1290 ), 1291 ) 1292 ], 1293 ), 1294 ), 1295 ('${C}', ex_line), 1296 ], 1297 ), 1298 color=(1, 0, 0), 1299 ) 1300 else: 1301 ba.textwidget( 1302 edit=text, 1303 text=ba.Lstr( 1304 resource='gatherWindow.' 'partyStatusJoinableText' 1305 ), 1306 color=(0, 1, 0), 1307 ) 1308 1309 def _do_status_check(self) -> None: 1310 from ba.internal import master_server_get 1311 1312 ba.textwidget( 1313 edit=self._host_status_text, 1314 color=(1, 1, 0), 1315 text=ba.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1316 ) 1317 master_server_get( 1318 'bsAccessCheck', 1319 {'b': ba.app.build_number}, 1320 callback=ba.WeakCall(self._on_public_party_accessible_response), 1321 ) 1322 1323 def _on_start_advertizing_press(self) -> None: 1324 from bastd.ui.account import show_sign_in_prompt 1325 1326 if ba.internal.get_v1_account_state() != 'signed_in': 1327 show_sign_in_prompt() 1328 return 1329 1330 name = cast(str, ba.textwidget(query=self._host_name_text)) 1331 if name == '': 1332 ba.screenmessage( 1333 ba.Lstr(resource='internal.invalidNameErrorText'), 1334 color=(1, 0, 0), 1335 ) 1336 ba.playsound(ba.getsound('error')) 1337 return 1338 ba.internal.set_public_party_name(name) 1339 cfg = ba.app.config 1340 cfg['Public Party Name'] = name 1341 cfg.commit() 1342 ba.playsound(ba.getsound('shieldUp')) 1343 ba.internal.set_public_party_enabled(True) 1344 1345 # In GUI builds we want to authenticate clients only when hosting 1346 # public parties. 1347 ba.internal.set_authenticate_clients(True) 1348 1349 self._do_status_check() 1350 ba.buttonwidget( 1351 edit=self._host_toggle_button, 1352 label=ba.Lstr( 1353 resource='gatherWindow.makePartyPrivateText', 1354 fallback_resource='gatherWindow.stopAdvertisingText', 1355 ), 1356 on_activate_call=self._on_stop_advertising_press, 1357 ) 1358 1359 def _on_stop_advertising_press(self) -> None: 1360 ba.internal.set_public_party_enabled(False) 1361 1362 # In GUI builds we want to authenticate clients only when hosting 1363 # public parties. 1364 ba.internal.set_authenticate_clients(False) 1365 ba.playsound(ba.getsound('shieldDown')) 1366 text = self._host_status_text 1367 if text: 1368 ba.textwidget( 1369 edit=text, 1370 text=ba.Lstr( 1371 resource='gatherWindow.' 'partyStatusNotPublicText' 1372 ), 1373 color=(0.6, 0.6, 0.6), 1374 ) 1375 ba.buttonwidget( 1376 edit=self._host_toggle_button, 1377 label=ba.Lstr( 1378 resource='gatherWindow.makePartyPublicText', 1379 fallback_resource='gatherWindow.startAdvertisingText', 1380 ), 1381 on_activate_call=self._on_start_advertizing_press, 1382 ) 1383 1384 def on_public_party_activate(self, party: PartyEntry) -> None: 1385 """Called when a party is clicked or otherwise activated.""" 1386 self.save_state() 1387 if party.queue is not None: 1388 from bastd.ui.partyqueue import PartyQueueWindow 1389 1390 ba.playsound(ba.getsound('swish')) 1391 PartyQueueWindow(party.queue, party.address, party.port) 1392 else: 1393 address = party.address 1394 port = party.port 1395 1396 # Rate limit this a bit. 1397 now = time.time() 1398 last_connect_time = self._last_connect_attempt_time 1399 if last_connect_time is None or now - last_connect_time > 2.0: 1400 ba.internal.connect_to_party(address, port=port) 1401 self._last_connect_attempt_time = now 1402 1403 def set_public_party_selection(self, sel: Selection) -> None: 1404 """Set the sel.""" 1405 if self._refreshing_list: 1406 return 1407 self._selection = sel 1408 self._have_user_selected_row = True 1409 1410 def _on_max_public_party_size_minus_press(self) -> None: 1411 val = max(1, ba.internal.get_public_party_max_size() - 1) 1412 ba.internal.set_public_party_max_size(val) 1413 ba.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1414 1415 def _on_max_public_party_size_plus_press(self) -> None: 1416 val = ba.internal.get_public_party_max_size() 1417 val += 1 1418 ba.internal.set_public_party_max_size(val) 1419 ba.textwidget(edit=self._host_max_party_size_value, text=str(val))
Available sub-tabs.
Inherited Members
- enum.Enum
- name
- value
36@dataclass 37class PartyEntry: 38 """Info about a public party.""" 39 40 address: str 41 index: int 42 queue: str | None = None 43 port: int = -1 44 name: str = '' 45 size: int = -1 46 size_max: int = -1 47 claimed: bool = False 48 ping: float | None = None 49 ping_interval: float = -1.0 50 next_ping_time: float = -1.0 51 ping_attempts: int = 0 52 ping_responses: int = 0 53 stats_addr: str | None = None 54 clean_display_index: int | None = None 55 56 def get_key(self) -> str: 57 """Return the key used to store this party.""" 58 return f'{self.address}_{self.port}'
Info about a public party.
61class UIRow: 62 """Wrangles UI for a row in the party list.""" 63 64 def __init__(self) -> None: 65 self._name_widget: ba.Widget | None = None 66 self._size_widget: ba.Widget | None = None 67 self._ping_widget: ba.Widget | None = None 68 self._stats_button: ba.Widget | None = None 69 70 def __del__(self) -> None: 71 self._clear() 72 73 def _clear(self) -> None: 74 for widget in [ 75 self._name_widget, 76 self._size_widget, 77 self._ping_widget, 78 self._stats_button, 79 ]: 80 if widget: 81 widget.delete() 82 83 def update( 84 self, 85 index: int, 86 party: PartyEntry, 87 sub_scroll_width: float, 88 sub_scroll_height: float, 89 lineheight: float, 90 columnwidget: ba.Widget, 91 join_text: ba.Widget, 92 filter_text: ba.Widget, 93 existing_selection: Selection | None, 94 tab: PublicGatherTab, 95 ) -> None: 96 """Update for the given data.""" 97 # pylint: disable=too-many-locals 98 99 # Quick-out: if we've been marked clean for a certain index and 100 # we're still at that index, we're done. 101 if party.clean_display_index == index: 102 return 103 104 ping_good = ba.internal.get_v1_account_misc_read_val('pingGood', 100) 105 ping_med = ba.internal.get_v1_account_misc_read_val('pingMed', 500) 106 107 self._clear() 108 hpos = 20 109 vpos = sub_scroll_height - lineheight * index - 50 110 self._name_widget = ba.textwidget( 111 text=ba.Lstr(value=party.name), 112 parent=columnwidget, 113 size=(sub_scroll_width * 0.63, 20), 114 position=(0 + hpos, 4 + vpos), 115 selectable=True, 116 on_select_call=ba.WeakCall( 117 tab.set_public_party_selection, 118 Selection(party.get_key(), SelectionComponent.NAME), 119 ), 120 on_activate_call=ba.WeakCall(tab.on_public_party_activate, party), 121 click_activate=True, 122 maxwidth=sub_scroll_width * 0.45, 123 corner_scale=1.4, 124 autoselect=True, 125 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 126 h_align='left', 127 v_align='center', 128 ) 129 ba.widget( 130 edit=self._name_widget, 131 left_widget=join_text, 132 show_buffer_top=64.0, 133 show_buffer_bottom=64.0, 134 ) 135 if existing_selection == Selection( 136 party.get_key(), SelectionComponent.NAME 137 ): 138 ba.containerwidget( 139 edit=columnwidget, selected_child=self._name_widget 140 ) 141 if party.stats_addr: 142 url = party.stats_addr.replace( 143 '${ACCOUNT}', 144 ba.internal.get_v1_account_misc_read_val_2( 145 'resolvedAccountID', 'UNKNOWN' 146 ), 147 ) 148 self._stats_button = ba.buttonwidget( 149 color=(0.3, 0.6, 0.94), 150 textcolor=(1.0, 1.0, 1.0), 151 label=ba.Lstr(resource='statsText'), 152 parent=columnwidget, 153 autoselect=True, 154 on_activate_call=ba.Call(ba.open_url, url), 155 on_select_call=ba.WeakCall( 156 tab.set_public_party_selection, 157 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 158 ), 159 size=(120, 40), 160 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 161 scale=0.9, 162 ) 163 if existing_selection == Selection( 164 party.get_key(), SelectionComponent.STATS_BUTTON 165 ): 166 ba.containerwidget( 167 edit=columnwidget, selected_child=self._stats_button 168 ) 169 170 self._size_widget = ba.textwidget( 171 text=str(party.size) + '/' + str(party.size_max), 172 parent=columnwidget, 173 size=(0, 0), 174 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 175 scale=0.7, 176 color=(0.8, 0.8, 0.8), 177 h_align='right', 178 v_align='center', 179 ) 180 181 if index == 0: 182 ba.widget(edit=self._name_widget, up_widget=filter_text) 183 if self._stats_button: 184 ba.widget(edit=self._stats_button, up_widget=filter_text) 185 186 self._ping_widget = ba.textwidget( 187 parent=columnwidget, 188 size=(0, 0), 189 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 190 scale=0.7, 191 h_align='right', 192 v_align='center', 193 ) 194 if party.ping is None: 195 ba.textwidget( 196 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 197 ) 198 else: 199 ba.textwidget( 200 edit=self._ping_widget, 201 text=str(int(party.ping)), 202 color=(0, 1, 0) 203 if party.ping <= ping_good 204 else (1, 1, 0) 205 if party.ping <= ping_med 206 else (1, 0, 0), 207 ) 208 209 party.clean_display_index = index
Wrangles UI for a row in the party list.
83 def update( 84 self, 85 index: int, 86 party: PartyEntry, 87 sub_scroll_width: float, 88 sub_scroll_height: float, 89 lineheight: float, 90 columnwidget: ba.Widget, 91 join_text: ba.Widget, 92 filter_text: ba.Widget, 93 existing_selection: Selection | None, 94 tab: PublicGatherTab, 95 ) -> None: 96 """Update for the given data.""" 97 # pylint: disable=too-many-locals 98 99 # Quick-out: if we've been marked clean for a certain index and 100 # we're still at that index, we're done. 101 if party.clean_display_index == index: 102 return 103 104 ping_good = ba.internal.get_v1_account_misc_read_val('pingGood', 100) 105 ping_med = ba.internal.get_v1_account_misc_read_val('pingMed', 500) 106 107 self._clear() 108 hpos = 20 109 vpos = sub_scroll_height - lineheight * index - 50 110 self._name_widget = ba.textwidget( 111 text=ba.Lstr(value=party.name), 112 parent=columnwidget, 113 size=(sub_scroll_width * 0.63, 20), 114 position=(0 + hpos, 4 + vpos), 115 selectable=True, 116 on_select_call=ba.WeakCall( 117 tab.set_public_party_selection, 118 Selection(party.get_key(), SelectionComponent.NAME), 119 ), 120 on_activate_call=ba.WeakCall(tab.on_public_party_activate, party), 121 click_activate=True, 122 maxwidth=sub_scroll_width * 0.45, 123 corner_scale=1.4, 124 autoselect=True, 125 color=(1, 1, 1, 0.3 if party.ping is None else 1.0), 126 h_align='left', 127 v_align='center', 128 ) 129 ba.widget( 130 edit=self._name_widget, 131 left_widget=join_text, 132 show_buffer_top=64.0, 133 show_buffer_bottom=64.0, 134 ) 135 if existing_selection == Selection( 136 party.get_key(), SelectionComponent.NAME 137 ): 138 ba.containerwidget( 139 edit=columnwidget, selected_child=self._name_widget 140 ) 141 if party.stats_addr: 142 url = party.stats_addr.replace( 143 '${ACCOUNT}', 144 ba.internal.get_v1_account_misc_read_val_2( 145 'resolvedAccountID', 'UNKNOWN' 146 ), 147 ) 148 self._stats_button = ba.buttonwidget( 149 color=(0.3, 0.6, 0.94), 150 textcolor=(1.0, 1.0, 1.0), 151 label=ba.Lstr(resource='statsText'), 152 parent=columnwidget, 153 autoselect=True, 154 on_activate_call=ba.Call(ba.open_url, url), 155 on_select_call=ba.WeakCall( 156 tab.set_public_party_selection, 157 Selection(party.get_key(), SelectionComponent.STATS_BUTTON), 158 ), 159 size=(120, 40), 160 position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), 161 scale=0.9, 162 ) 163 if existing_selection == Selection( 164 party.get_key(), SelectionComponent.STATS_BUTTON 165 ): 166 ba.containerwidget( 167 edit=columnwidget, selected_child=self._stats_button 168 ) 169 170 self._size_widget = ba.textwidget( 171 text=str(party.size) + '/' + str(party.size_max), 172 parent=columnwidget, 173 size=(0, 0), 174 position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), 175 scale=0.7, 176 color=(0.8, 0.8, 0.8), 177 h_align='right', 178 v_align='center', 179 ) 180 181 if index == 0: 182 ba.widget(edit=self._name_widget, up_widget=filter_text) 183 if self._stats_button: 184 ba.widget(edit=self._stats_button, up_widget=filter_text) 185 186 self._ping_widget = ba.textwidget( 187 parent=columnwidget, 188 size=(0, 0), 189 position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), 190 scale=0.7, 191 h_align='right', 192 v_align='center', 193 ) 194 if party.ping is None: 195 ba.textwidget( 196 edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) 197 ) 198 else: 199 ba.textwidget( 200 edit=self._ping_widget, 201 text=str(int(party.ping)), 202 color=(0, 1, 0) 203 if party.ping <= ping_good 204 else (1, 1, 0) 205 if party.ping <= ping_med 206 else (1, 0, 0), 207 ) 208 209 party.clean_display_index = index
Update for the given data.
212@dataclass 213class State: 214 """State saved/restored only while the app is running.""" 215 216 sub_tab: SubTabType = SubTabType.JOIN 217 parties: list[tuple[str, PartyEntry]] | None = None 218 next_entry_index: int = 0 219 filter_value: str = '' 220 have_server_list_response: bool = False 221 have_valid_server_list: bool = False
State saved/restored only while the app is running.
224class SelectionComponent(Enum): 225 """Describes what part of an entry is selected.""" 226 227 NAME = 'name' 228 STATS_BUTTON = 'stats_button'
Describes what part of an entry is selected.
Inherited Members
- enum.Enum
- name
- value
231@dataclass 232class Selection: 233 """Describes the currently selected list element.""" 234 235 entry_key: str 236 component: SelectionComponent
Describes the currently selected list element.
239class AddrFetchThread(threading.Thread): 240 """Thread for fetching an address in the bg.""" 241 242 def __init__(self, call: Callable[[Any], Any]): 243 super().__init__() 244 self._call = call 245 246 def run(self) -> None: 247 try: 248 # FIXME: Update this to work with IPv6 at some point. 249 import socket 250 251 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 252 sock.connect(('8.8.8.8', 80)) 253 val = sock.getsockname()[0] 254 sock.close() 255 ba.pushcall(ba.Call(self._call, val), from_other_thread=True) 256 except Exception as exc: 257 from efro.error import is_udp_communication_error 258 259 # Ignore expected network errors; log others. 260 if is_udp_communication_error(exc): 261 pass 262 else: 263 ba.print_exception()
Thread for fetching an address in the bg.
This constructor should always be called with keyword arguments. Arguments are:
group should be None; reserved for future extension when a ThreadGroup class is implemented.
target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.
name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.
args is the argument tuple for the target invocation. Defaults to ().
kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.
If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.
246 def run(self) -> None: 247 try: 248 # FIXME: Update this to work with IPv6 at some point. 249 import socket 250 251 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 252 sock.connect(('8.8.8.8', 80)) 253 val = sock.getsockname()[0] 254 sock.close() 255 ba.pushcall(ba.Call(self._call, val), from_other_thread=True) 256 except Exception as exc: 257 from efro.error import is_udp_communication_error 258 259 # Ignore expected network errors; log others. 260 if is_udp_communication_error(exc): 261 pass 262 else: 263 ba.print_exception()
Method representing the thread's activity.
You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.
Inherited Members
- threading.Thread
- start
- join
- name
- ident
- is_alive
- daemon
- isDaemon
- setDaemon
- getName
- setName
- native_id
266class PingThread(threading.Thread): 267 """Thread for sending out game pings.""" 268 269 def __init__( 270 self, 271 address: str, 272 port: int, 273 call: Callable[[str, int, float | None], int | None], 274 ): 275 super().__init__() 276 self._address = address 277 self._port = port 278 self._call = call 279 280 def run(self) -> None: 281 ba.app.ping_thread_count += 1 282 sock: socket.socket | None = None 283 try: 284 import socket 285 from ba.internal import get_ip_address_type 286 287 socket_type = get_ip_address_type(self._address) 288 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 289 sock.connect((self._address, self._port)) 290 291 accessible = False 292 starttime = time.time() 293 294 # Send a few pings and wait a second for 295 # a response. 296 sock.settimeout(1) 297 for _i in range(3): 298 sock.send(b'\x0b') 299 result: bytes | None 300 try: 301 # 11: BA_PACKET_SIMPLE_PING 302 result = sock.recv(10) 303 except Exception: 304 result = None 305 if result == b'\x0c': 306 # 12: BA_PACKET_SIMPLE_PONG 307 accessible = True 308 break 309 time.sleep(1) 310 ping = (time.time() - starttime) * 1000.0 311 ba.pushcall( 312 ba.Call( 313 self._call, 314 self._address, 315 self._port, 316 ping if accessible else None, 317 ), 318 from_other_thread=True, 319 ) 320 except Exception as exc: 321 from efro.error import is_udp_communication_error 322 323 if is_udp_communication_error(exc): 324 pass 325 else: 326 ba.print_exception('Error on gather ping', once=True) 327 finally: 328 try: 329 if sock is not None: 330 sock.close() 331 except Exception: 332 ba.print_exception('Error on gather ping cleanup', once=True) 333 334 ba.app.ping_thread_count -= 1
Thread for sending out game pings.
269 def __init__( 270 self, 271 address: str, 272 port: int, 273 call: Callable[[str, int, float | None], int | None], 274 ): 275 super().__init__() 276 self._address = address 277 self._port = port 278 self._call = call
This constructor should always be called with keyword arguments. Arguments are:
group should be None; reserved for future extension when a ThreadGroup class is implemented.
target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.
name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.
args is the argument tuple for the target invocation. Defaults to ().
kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.
If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.
280 def run(self) -> None: 281 ba.app.ping_thread_count += 1 282 sock: socket.socket | None = None 283 try: 284 import socket 285 from ba.internal import get_ip_address_type 286 287 socket_type = get_ip_address_type(self._address) 288 sock = socket.socket(socket_type, socket.SOCK_DGRAM) 289 sock.connect((self._address, self._port)) 290 291 accessible = False 292 starttime = time.time() 293 294 # Send a few pings and wait a second for 295 # a response. 296 sock.settimeout(1) 297 for _i in range(3): 298 sock.send(b'\x0b') 299 result: bytes | None 300 try: 301 # 11: BA_PACKET_SIMPLE_PING 302 result = sock.recv(10) 303 except Exception: 304 result = None 305 if result == b'\x0c': 306 # 12: BA_PACKET_SIMPLE_PONG 307 accessible = True 308 break 309 time.sleep(1) 310 ping = (time.time() - starttime) * 1000.0 311 ba.pushcall( 312 ba.Call( 313 self._call, 314 self._address, 315 self._port, 316 ping if accessible else None, 317 ), 318 from_other_thread=True, 319 ) 320 except Exception as exc: 321 from efro.error import is_udp_communication_error 322 323 if is_udp_communication_error(exc): 324 pass 325 else: 326 ba.print_exception('Error on gather ping', once=True) 327 finally: 328 try: 329 if sock is not None: 330 sock.close() 331 except Exception: 332 ba.print_exception('Error on gather ping cleanup', once=True) 333 334 ba.app.ping_thread_count -= 1
Method representing the thread's activity.
You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.
Inherited Members
- threading.Thread
- start
- join
- name
- ident
- is_alive
- daemon
- isDaemon
- setDaemon
- getName
- setName
- native_id
337class PublicGatherTab(GatherTab): 338 """The public tab in the gather UI""" 339 340 def __init__(self, window: GatherWindow) -> None: 341 super().__init__(window) 342 self._container: ba.Widget | None = None 343 self._join_text: ba.Widget | None = None 344 self._host_text: ba.Widget | None = None 345 self._filter_text: ba.Widget | None = None 346 self._local_address: str | None = None 347 self._last_connect_attempt_time: float | None = None 348 self._sub_tab: SubTabType = SubTabType.JOIN 349 self._selection: Selection | None = None 350 self._refreshing_list = False 351 self._update_timer: ba.Timer | None = None 352 self._host_scrollwidget: ba.Widget | None = None 353 self._host_name_text: ba.Widget | None = None 354 self._host_toggle_button: ba.Widget | None = None 355 self._last_server_list_query_time: float | None = None 356 self._join_list_column: ba.Widget | None = None 357 self._join_status_text: ba.Widget | None = None 358 self._host_max_party_size_value: ba.Widget | None = None 359 self._host_max_party_size_minus_button: (ba.Widget | None) = None 360 self._host_max_party_size_plus_button: (ba.Widget | None) = None 361 self._host_status_text: ba.Widget | None = None 362 self._signed_in = False 363 self._ui_rows: list[UIRow] = [] 364 self._refresh_ui_row = 0 365 self._have_user_selected_row = False 366 self._first_valid_server_list_time: float | None = None 367 368 # Parties indexed by id: 369 self._parties: dict[str, PartyEntry] = {} 370 371 # Parties sorted in display order: 372 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 373 self._party_lists_dirty = True 374 375 # Sorted parties with filter applied: 376 self._parties_displayed: dict[str, PartyEntry] = {} 377 378 self._next_entry_index = 0 379 self._have_server_list_response = False 380 self._have_valid_server_list = False 381 self._filter_value = '' 382 self._pending_party_infos: list[dict[str, Any]] = [] 383 self._last_sub_scroll_height = 0.0 384 385 def on_activate( 386 self, 387 parent_widget: ba.Widget, 388 tab_button: ba.Widget, 389 region_width: float, 390 region_height: float, 391 region_left: float, 392 region_bottom: float, 393 ) -> ba.Widget: 394 c_width = region_width 395 c_height = region_height - 20 396 self._container = ba.containerwidget( 397 parent=parent_widget, 398 position=( 399 region_left, 400 region_bottom + (region_height - c_height) * 0.5, 401 ), 402 size=(c_width, c_height), 403 background=False, 404 selection_loops_to_parent=True, 405 ) 406 v = c_height - 30 407 self._join_text = ba.textwidget( 408 parent=self._container, 409 position=(c_width * 0.5 - 245, v - 13), 410 color=(0.6, 1.0, 0.6), 411 scale=1.3, 412 size=(200, 30), 413 maxwidth=250, 414 h_align='left', 415 v_align='center', 416 click_activate=True, 417 selectable=True, 418 autoselect=True, 419 on_activate_call=lambda: self._set_sub_tab( 420 SubTabType.JOIN, 421 region_width, 422 region_height, 423 playsound=True, 424 ), 425 text=ba.Lstr( 426 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 427 ), 428 ) 429 self._host_text = ba.textwidget( 430 parent=self._container, 431 position=(c_width * 0.5 + 45, v - 13), 432 color=(0.6, 1.0, 0.6), 433 scale=1.3, 434 size=(200, 30), 435 maxwidth=250, 436 h_align='left', 437 v_align='center', 438 click_activate=True, 439 selectable=True, 440 autoselect=True, 441 on_activate_call=lambda: self._set_sub_tab( 442 SubTabType.HOST, 443 region_width, 444 region_height, 445 playsound=True, 446 ), 447 text=ba.Lstr( 448 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 449 ), 450 ) 451 ba.widget(edit=self._join_text, up_widget=tab_button) 452 ba.widget( 453 edit=self._host_text, 454 left_widget=self._join_text, 455 up_widget=tab_button, 456 ) 457 ba.widget(edit=self._join_text, right_widget=self._host_text) 458 459 # Attempt to fetch our local address so we have it for error messages. 460 if self._local_address is None: 461 AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start() 462 463 self._set_sub_tab(self._sub_tab, region_width, region_height) 464 self._update_timer = ba.Timer( 465 0.1, 466 ba.WeakCall(self._update), 467 repeat=True, 468 timetype=ba.TimeType.REAL, 469 ) 470 return self._container 471 472 def on_deactivate(self) -> None: 473 self._update_timer = None 474 475 def save_state(self) -> None: 476 477 # Save off a small number of parties with the lowest ping; we'll 478 # display these immediately when our UI comes back up which should 479 # be enough to make things feel nice and crisp while we do a full 480 # server re-query or whatnot. 481 ba.app.ui.window_states[type(self)] = State( 482 sub_tab=self._sub_tab, 483 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 484 next_entry_index=self._next_entry_index, 485 filter_value=self._filter_value, 486 have_server_list_response=self._have_server_list_response, 487 have_valid_server_list=self._have_valid_server_list, 488 ) 489 490 def restore_state(self) -> None: 491 state = ba.app.ui.window_states.get(type(self)) 492 if state is None: 493 state = State() 494 assert isinstance(state, State) 495 self._sub_tab = state.sub_tab 496 497 # Restore the parties we stored. 498 if state.parties: 499 self._parties = { 500 key: copy.copy(party) for key, party in state.parties 501 } 502 self._parties_sorted = list(self._parties.items()) 503 self._party_lists_dirty = True 504 505 self._next_entry_index = state.next_entry_index 506 507 # FIXME: should save/restore these too?.. 508 self._have_server_list_response = state.have_server_list_response 509 self._have_valid_server_list = state.have_valid_server_list 510 self._filter_value = state.filter_value 511 512 def _set_sub_tab( 513 self, 514 value: SubTabType, 515 region_width: float, 516 region_height: float, 517 playsound: bool = False, 518 ) -> None: 519 assert self._container 520 if playsound: 521 ba.playsound(ba.getsound('click01')) 522 523 # Reset our selection. 524 # (prevents selecting something way down the list if we switched away 525 # and came back) 526 self._selection = None 527 self._have_user_selected_row = False 528 529 # Reset refresh to the top and make sure everything refreshes. 530 self._refresh_ui_row = 0 531 for party in self._parties.values(): 532 party.clean_display_index = None 533 534 self._sub_tab = value 535 active_color = (0.6, 1.0, 0.6) 536 inactive_color = (0.5, 0.4, 0.5) 537 ba.textwidget( 538 edit=self._join_text, 539 color=active_color if value is SubTabType.JOIN else inactive_color, 540 ) 541 ba.textwidget( 542 edit=self._host_text, 543 color=active_color if value is SubTabType.HOST else inactive_color, 544 ) 545 546 # Clear anything existing in the old sub-tab. 547 for widget in self._container.get_children(): 548 if widget and widget not in {self._host_text, self._join_text}: 549 widget.delete() 550 551 if value is SubTabType.JOIN: 552 self._build_join_tab(region_width, region_height) 553 554 if value is SubTabType.HOST: 555 self._build_host_tab(region_width, region_height) 556 557 def _build_join_tab( 558 self, region_width: float, region_height: float 559 ) -> None: 560 c_width = region_width 561 c_height = region_height - 20 562 sub_scroll_height = c_height - 125 563 sub_scroll_width = 830 564 v = c_height - 35 565 v -= 60 566 filter_txt = ba.Lstr(resource='filterText') 567 self._filter_text = ba.textwidget( 568 parent=self._container, 569 text=self._filter_value, 570 size=(350, 45), 571 position=(290, v - 10), 572 h_align='left', 573 v_align='center', 574 editable=True, 575 maxwidth=310, 576 description=filter_txt, 577 ) 578 ba.widget(edit=self._filter_text, up_widget=self._join_text) 579 ba.textwidget( 580 text=filter_txt, 581 parent=self._container, 582 size=(0, 0), 583 position=(270, v + 13), 584 maxwidth=150, 585 scale=0.8, 586 color=(0.5, 0.46, 0.5), 587 flatness=1.0, 588 h_align='right', 589 v_align='center', 590 ) 591 592 ba.textwidget( 593 text=ba.Lstr(resource='nameText'), 594 parent=self._container, 595 size=(0, 0), 596 position=(90, v - 8), 597 maxwidth=60, 598 scale=0.6, 599 color=(0.5, 0.46, 0.5), 600 flatness=1.0, 601 h_align='center', 602 v_align='center', 603 ) 604 ba.textwidget( 605 text=ba.Lstr(resource='gatherWindow.partySizeText'), 606 parent=self._container, 607 size=(0, 0), 608 position=(755, v - 8), 609 maxwidth=60, 610 scale=0.6, 611 color=(0.5, 0.46, 0.5), 612 flatness=1.0, 613 h_align='center', 614 v_align='center', 615 ) 616 ba.textwidget( 617 text=ba.Lstr(resource='gatherWindow.pingText'), 618 parent=self._container, 619 size=(0, 0), 620 position=(825, v - 8), 621 maxwidth=60, 622 scale=0.6, 623 color=(0.5, 0.46, 0.5), 624 flatness=1.0, 625 h_align='center', 626 v_align='center', 627 ) 628 v -= sub_scroll_height + 23 629 self._host_scrollwidget = scrollw = ba.scrollwidget( 630 parent=self._container, 631 simple_culling_v=10, 632 position=((c_width - sub_scroll_width) * 0.5, v), 633 size=(sub_scroll_width, sub_scroll_height), 634 claims_up_down=False, 635 claims_left_right=True, 636 autoselect=True, 637 ) 638 self._join_list_column = ba.containerwidget( 639 parent=scrollw, 640 background=False, 641 size=(400, 400), 642 claims_left_right=True, 643 ) 644 self._join_status_text = ba.textwidget( 645 parent=self._container, 646 text='', 647 size=(0, 0), 648 scale=0.9, 649 flatness=1.0, 650 shadow=0.0, 651 h_align='center', 652 v_align='top', 653 maxwidth=c_width, 654 color=(0.6, 0.6, 0.6), 655 position=(c_width * 0.5, c_height * 0.5), 656 ) 657 658 def _build_host_tab( 659 self, region_width: float, region_height: float 660 ) -> None: 661 c_width = region_width 662 c_height = region_height - 20 663 v = c_height - 35 664 v -= 25 665 is_public_enabled = ba.internal.get_public_party_enabled() 666 v -= 30 667 668 ba.textwidget( 669 parent=self._container, 670 size=(0, 0), 671 h_align='center', 672 v_align='center', 673 maxwidth=c_width * 0.9, 674 scale=0.7, 675 flatness=1.0, 676 color=(0.5, 0.46, 0.5), 677 position=(region_width * 0.5, v + 10), 678 text=ba.Lstr(resource='gatherWindow.publicHostRouterConfigText'), 679 ) 680 v -= 30 681 682 party_name_text = ba.Lstr( 683 resource='gatherWindow.partyNameText', 684 fallback_resource='editGameListWindow.nameText', 685 ) 686 ba.textwidget( 687 parent=self._container, 688 size=(0, 0), 689 h_align='right', 690 v_align='center', 691 maxwidth=200, 692 scale=0.8, 693 color=ba.app.ui.infotextcolor, 694 position=(210, v - 9), 695 text=party_name_text, 696 ) 697 self._host_name_text = ba.textwidget( 698 parent=self._container, 699 editable=True, 700 size=(535, 40), 701 position=(230, v - 30), 702 text=ba.app.config.get('Public Party Name', ''), 703 maxwidth=494, 704 shadow=0.3, 705 flatness=1.0, 706 description=party_name_text, 707 autoselect=True, 708 v_align='center', 709 corner_scale=1.0, 710 ) 711 712 v -= 60 713 ba.textwidget( 714 parent=self._container, 715 size=(0, 0), 716 h_align='right', 717 v_align='center', 718 maxwidth=200, 719 scale=0.8, 720 color=ba.app.ui.infotextcolor, 721 position=(210, v - 9), 722 text=ba.Lstr( 723 resource='maxPartySizeText', 724 fallback_resource='maxConnectionsText', 725 ), 726 ) 727 self._host_max_party_size_value = ba.textwidget( 728 parent=self._container, 729 size=(0, 0), 730 h_align='center', 731 v_align='center', 732 scale=1.2, 733 color=(1, 1, 1), 734 position=(240, v - 9), 735 text=str(ba.internal.get_public_party_max_size()), 736 ) 737 btn1 = self._host_max_party_size_minus_button = ba.buttonwidget( 738 parent=self._container, 739 size=(40, 40), 740 on_activate_call=ba.WeakCall( 741 self._on_max_public_party_size_minus_press 742 ), 743 position=(280, v - 26), 744 label='-', 745 autoselect=True, 746 ) 747 btn2 = self._host_max_party_size_plus_button = ba.buttonwidget( 748 parent=self._container, 749 size=(40, 40), 750 on_activate_call=ba.WeakCall( 751 self._on_max_public_party_size_plus_press 752 ), 753 position=(350, v - 26), 754 label='+', 755 autoselect=True, 756 ) 757 v -= 50 758 v -= 70 759 if is_public_enabled: 760 label = ba.Lstr( 761 resource='gatherWindow.makePartyPrivateText', 762 fallback_resource='gatherWindow.stopAdvertisingText', 763 ) 764 else: 765 label = ba.Lstr( 766 resource='gatherWindow.makePartyPublicText', 767 fallback_resource='gatherWindow.startAdvertisingText', 768 ) 769 self._host_toggle_button = ba.buttonwidget( 770 parent=self._container, 771 label=label, 772 size=(400, 80), 773 on_activate_call=self._on_stop_advertising_press 774 if is_public_enabled 775 else self._on_start_advertizing_press, 776 position=(c_width * 0.5 - 200, v), 777 autoselect=True, 778 up_widget=btn2, 779 ) 780 ba.widget(edit=self._host_name_text, down_widget=btn2) 781 ba.widget(edit=btn2, up_widget=self._host_name_text) 782 ba.widget(edit=btn1, up_widget=self._host_name_text) 783 ba.widget(edit=self._join_text, down_widget=self._host_name_text) 784 v -= 10 785 self._host_status_text = ba.textwidget( 786 parent=self._container, 787 text=ba.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), 788 size=(0, 0), 789 scale=0.7, 790 flatness=1.0, 791 h_align='center', 792 v_align='top', 793 maxwidth=c_width * 0.9, 794 color=(0.6, 0.56, 0.6), 795 position=(c_width * 0.5, v), 796 ) 797 v -= 90 798 ba.textwidget( 799 parent=self._container, 800 text=ba.Lstr(resource='gatherWindow.dedicatedServerInfoText'), 801 size=(0, 0), 802 scale=0.7, 803 flatness=1.0, 804 h_align='center', 805 v_align='center', 806 maxwidth=c_width * 0.9, 807 color=(0.5, 0.46, 0.5), 808 position=(c_width * 0.5, v), 809 ) 810 811 # If public sharing is already on, 812 # launch a status-check immediately. 813 if ba.internal.get_public_party_enabled(): 814 self._do_status_check() 815 816 def _on_public_party_query_result( 817 self, result: dict[str, Any] | None 818 ) -> None: 819 starttime = time.time() 820 self._have_server_list_response = True 821 822 if result is None: 823 self._have_valid_server_list = False 824 return 825 826 if not self._have_valid_server_list: 827 self._first_valid_server_list_time = time.time() 828 829 self._have_valid_server_list = True 830 parties_in = result['l'] 831 832 assert isinstance(parties_in, list) 833 self._pending_party_infos += parties_in 834 835 # To avoid causing a stutter here, we do most processing of 836 # these entries incrementally in our _update() method. 837 # The one thing we do here is prune parties not contained in 838 # this result. 839 for partyval in list(self._parties.values()): 840 partyval.claimed = False 841 for party_in in parties_in: 842 addr = party_in['a'] 843 assert isinstance(addr, str) 844 port = party_in['p'] 845 assert isinstance(port, int) 846 party_key = f'{addr}_{port}' 847 party = self._parties.get(party_key) 848 if party is not None: 849 party.claimed = True 850 self._parties = { 851 key: val for key, val in list(self._parties.items()) if val.claimed 852 } 853 self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] 854 self._party_lists_dirty = True 855 856 # self._update_server_list() 857 if DEBUG_PROCESSING: 858 print( 859 f'Handled public party query results in ' 860 f'{time.time()-starttime:.5f}s.' 861 ) 862 863 def _update(self) -> None: 864 """Periodic updating.""" 865 866 # Special case: if a party-queue window is up, don't do any of this 867 # (keeps things smoother). 868 # if ba.app.ui.have_party_queue_window: 869 # return 870 871 if self._sub_tab is SubTabType.JOIN: 872 873 # Keep our filter-text up to date from the UI. 874 text = self._filter_text 875 if text: 876 filter_value = cast(str, ba.textwidget(query=text)) 877 if filter_value != self._filter_value: 878 self._filter_value = filter_value 879 self._party_lists_dirty = True 880 881 # Also wipe out party clean-row states. 882 # (otherwise if a party disappears from a row due to 883 # filtering and then reappears on that same row when 884 # the filter is removed it may not update) 885 for party in self._parties.values(): 886 party.clean_display_index = None 887 888 self._query_party_list_periodically() 889 self._ping_parties_periodically() 890 891 # If any new party infos have come in, apply some of them. 892 self._process_pending_party_infos() 893 894 # Anytime we sign in/out, make sure we refresh our list. 895 signed_in = ba.internal.get_v1_account_state() == 'signed_in' 896 if self._signed_in != signed_in: 897 self._signed_in = signed_in 898 self._party_lists_dirty = True 899 900 # Update sorting to account for ping updates, new parties, etc. 901 self._update_party_lists() 902 903 # If we've got a party-name text widget, keep its value plugged 904 # into our public host name. 905 text = self._host_name_text 906 if text: 907 name = cast(str, ba.textwidget(query=self._host_name_text)) 908 ba.internal.set_public_party_name(name) 909 910 # Update status text. 911 status_text = self._join_status_text 912 if status_text: 913 if not signed_in: 914 ba.textwidget( 915 edit=status_text, text=ba.Lstr(resource='notSignedInText') 916 ) 917 else: 918 # If we have a valid list, show no status; just the list. 919 # Otherwise show either 'loading...' or 'error' depending 920 # on whether this is our first go-round. 921 if self._have_valid_server_list: 922 ba.textwidget(edit=status_text, text='') 923 else: 924 if self._have_server_list_response: 925 ba.textwidget( 926 edit=status_text, text=ba.Lstr(resource='errorText') 927 ) 928 else: 929 ba.textwidget( 930 edit=status_text, 931 text=ba.Lstr( 932 value='${A}...', 933 subs=[ 934 ( 935 '${A}', 936 ba.Lstr(resource='store.loadingText'), 937 ) 938 ], 939 ), 940 ) 941 942 self._update_party_rows() 943 944 def _update_party_rows(self) -> None: 945 columnwidget = self._join_list_column 946 if not columnwidget: 947 return 948 949 assert self._join_text 950 assert self._filter_text 951 952 # Janky - allow escaping when there's nothing in our list. 953 assert self._host_scrollwidget 954 ba.containerwidget( 955 edit=self._host_scrollwidget, 956 claims_up_down=(len(self._parties_displayed) > 0), 957 ) 958 959 # Clip if we have more UI rows than parties to show. 960 clipcount = len(self._ui_rows) - len(self._parties_displayed) 961 if clipcount > 0: 962 clipcount = max(clipcount, 50) 963 self._ui_rows = self._ui_rows[:-clipcount] 964 965 # If we have no parties to show, we're done. 966 if not self._parties_displayed: 967 return 968 969 sub_scroll_width = 830 970 lineheight = 42 971 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 972 ba.containerwidget( 973 edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) 974 ) 975 976 # Any time our height changes, reset the refresh back to the top 977 # so we don't see ugly empty spaces appearing during initial list 978 # filling. 979 if sub_scroll_height != self._last_sub_scroll_height: 980 self._refresh_ui_row = 0 981 self._last_sub_scroll_height = sub_scroll_height 982 983 # Also note that we need to redisplay everything since its pos 984 # will have changed.. :( 985 for party in self._parties.values(): 986 party.clean_display_index = None 987 988 # Ew; this rebuilding generates deferred selection callbacks 989 # so we need to push deferred notices so we know to ignore them. 990 def refresh_on() -> None: 991 self._refreshing_list = True 992 993 ba.pushcall(refresh_on) 994 995 # Ok, now here's the deal: we want to avoid creating/updating this 996 # entire list at one time because it will lead to hitches. So we 997 # refresh individual rows quickly in a loop. 998 rowcount = min(12, len(self._parties_displayed)) 999 1000 party_vals_displayed = list(self._parties_displayed.values()) 1001 while rowcount > 0: 1002 refresh_row = self._refresh_ui_row % len(self._parties_displayed) 1003 if refresh_row >= len(self._ui_rows): 1004 self._ui_rows.append(UIRow()) 1005 refresh_row = len(self._ui_rows) - 1 1006 1007 # For the first few seconds after getting our first server-list, 1008 # refresh only the top section of the list; this allows the lowest 1009 # ping servers to show up more quickly. 1010 if self._first_valid_server_list_time is not None: 1011 if time.time() - self._first_valid_server_list_time < 4.0: 1012 if refresh_row > 40: 1013 refresh_row = 0 1014 1015 self._ui_rows[refresh_row].update( 1016 refresh_row, 1017 party_vals_displayed[refresh_row], 1018 sub_scroll_width=sub_scroll_width, 1019 sub_scroll_height=sub_scroll_height, 1020 lineheight=lineheight, 1021 columnwidget=columnwidget, 1022 join_text=self._join_text, 1023 existing_selection=self._selection, 1024 filter_text=self._filter_text, 1025 tab=self, 1026 ) 1027 self._refresh_ui_row = refresh_row + 1 1028 rowcount -= 1 1029 1030 # So our selection callbacks can start firing.. 1031 def refresh_off() -> None: 1032 self._refreshing_list = False 1033 1034 ba.pushcall(refresh_off) 1035 1036 def _process_pending_party_infos(self) -> None: 1037 starttime = time.time() 1038 1039 # We want to do this in small enough pieces to not cause UI hitches. 1040 chunksize = 30 1041 parties_in = self._pending_party_infos[:chunksize] 1042 self._pending_party_infos = self._pending_party_infos[chunksize:] 1043 for party_in in parties_in: 1044 addr = party_in['a'] 1045 assert isinstance(addr, str) 1046 port = party_in['p'] 1047 assert isinstance(port, int) 1048 party_key = f'{addr}_{port}' 1049 party = self._parties.get(party_key) 1050 if party is None: 1051 # If this party is new to us, init it. 1052 party = PartyEntry( 1053 address=addr, 1054 next_ping_time=ba.time(ba.TimeType.REAL) 1055 + 0.001 * party_in['pd'], 1056 index=self._next_entry_index, 1057 ) 1058 self._parties[party_key] = party 1059 self._parties_sorted.append((party_key, party)) 1060 self._party_lists_dirty = True 1061 self._next_entry_index += 1 1062 assert isinstance(party.address, str) 1063 assert isinstance(party.next_ping_time, float) 1064 1065 # Now, new or not, update its values. 1066 party.queue = party_in.get('q') 1067 assert isinstance(party.queue, (str, type(None))) 1068 party.port = port 1069 party.name = party_in['n'] 1070 assert isinstance(party.name, str) 1071 party.size = party_in['s'] 1072 assert isinstance(party.size, int) 1073 party.size_max = party_in['sm'] 1074 assert isinstance(party.size_max, int) 1075 1076 # Server provides this in milliseconds; we use seconds. 1077 party.ping_interval = 0.001 * party_in['pi'] 1078 assert isinstance(party.ping_interval, float) 1079 party.stats_addr = party_in['sa'] 1080 assert isinstance(party.stats_addr, (str, type(None))) 1081 1082 # Make sure the party's UI gets updated. 1083 party.clean_display_index = None 1084 1085 if DEBUG_PROCESSING and parties_in: 1086 print( 1087 f'Processed {len(parties_in)} raw party infos in' 1088 f' {time.time()-starttime:.5f}s.' 1089 ) 1090 1091 def _update_party_lists(self) -> None: 1092 if not self._party_lists_dirty: 1093 return 1094 starttime = time.time() 1095 assert len(self._parties_sorted) == len(self._parties) 1096 1097 self._parties_sorted.sort( 1098 key=lambda p: ( 1099 p[1].ping if p[1].ping is not None else 999999.0, 1100 p[1].index, 1101 ) 1102 ) 1103 1104 # If signed out or errored, show no parties. 1105 if ( 1106 ba.internal.get_v1_account_state() != 'signed_in' 1107 or not self._have_valid_server_list 1108 ): 1109 self._parties_displayed = {} 1110 else: 1111 if self._filter_value: 1112 filterval = self._filter_value.lower() 1113 self._parties_displayed = { 1114 k: v 1115 for k, v in self._parties_sorted 1116 if filterval in v.name.lower() 1117 } 1118 else: 1119 self._parties_displayed = dict(self._parties_sorted) 1120 1121 # Any time our selection disappears from the displayed list, go back to 1122 # auto-selecting the top entry. 1123 if ( 1124 self._selection is not None 1125 and self._selection.entry_key not in self._parties_displayed 1126 ): 1127 self._have_user_selected_row = False 1128 1129 # Whenever the user hasn't selected something, keep the first visible 1130 # row selected. 1131 if not self._have_user_selected_row and self._parties_displayed: 1132 firstpartykey = next(iter(self._parties_displayed)) 1133 self._selection = Selection(firstpartykey, SelectionComponent.NAME) 1134 1135 self._party_lists_dirty = False 1136 if DEBUG_PROCESSING: 1137 print( 1138 f'Sorted {len(self._parties_sorted)} parties in' 1139 f' {time.time()-starttime:.5f}s.' 1140 ) 1141 1142 def _query_party_list_periodically(self) -> None: 1143 now = ba.time(ba.TimeType.REAL) 1144 1145 # Fire off a new public-party query periodically. 1146 if ( 1147 self._last_server_list_query_time is None 1148 or now - self._last_server_list_query_time 1149 > 0.001 1150 * ba.internal.get_v1_account_misc_read_val( 1151 'pubPartyRefreshMS', 10000 1152 ) 1153 ): 1154 self._last_server_list_query_time = now 1155 if DEBUG_SERVER_COMMUNICATION: 1156 print('REQUESTING SERVER LIST') 1157 if ba.internal.get_v1_account_state() == 'signed_in': 1158 ba.internal.add_transaction( 1159 { 1160 'type': 'PUBLIC_PARTY_QUERY', 1161 'proto': ba.app.protocol_version, 1162 'lang': ba.app.lang.language, 1163 }, 1164 callback=ba.WeakCall(self._on_public_party_query_result), 1165 ) 1166 ba.internal.run_transactions() 1167 else: 1168 self._on_public_party_query_result(None) 1169 1170 def _ping_parties_periodically(self) -> None: 1171 now = ba.time(ba.TimeType.REAL) 1172 1173 # Go through our existing public party entries firing off pings 1174 # for any that have timed out. 1175 for party in list(self._parties.values()): 1176 if party.next_ping_time <= now and ba.app.ping_thread_count < 15: 1177 1178 # Crank the interval up for high-latency or non-responding 1179 # parties to save us some useless work. 1180 mult = 1 1181 if party.ping_responses == 0: 1182 if party.ping_attempts > 4: 1183 mult = 10 1184 elif party.ping_attempts > 2: 1185 mult = 5 1186 if party.ping is not None: 1187 mult = ( 1188 10 if party.ping > 300 else 5 if party.ping > 150 else 2 1189 ) 1190 1191 interval = party.ping_interval * mult 1192 if DEBUG_SERVER_COMMUNICATION: 1193 print( 1194 f'pinging #{party.index} cur={party.ping} ' 1195 f'interval={interval} ' 1196 f'({party.ping_responses}/{party.ping_attempts})' 1197 ) 1198 1199 party.next_ping_time = now + party.ping_interval * mult 1200 party.ping_attempts += 1 1201 1202 PingThread( 1203 party.address, party.port, ba.WeakCall(self._ping_callback) 1204 ).start() 1205 1206 def _ping_callback( 1207 self, address: str, port: int | None, result: float | None 1208 ) -> None: 1209 # Look for a widget corresponding to this target. 1210 # If we find one, update our list. 1211 party_key = f'{address}_{port}' 1212 party = self._parties.get(party_key) 1213 if party is not None: 1214 if result is not None: 1215 party.ping_responses += 1 1216 1217 # We now smooth ping a bit to reduce jumping around in the list 1218 # (only where pings are relatively good). 1219 current_ping = party.ping 1220 if current_ping is not None and result is not None and result < 150: 1221 smoothing = 0.7 1222 party.ping = ( 1223 smoothing * current_ping + (1.0 - smoothing) * result 1224 ) 1225 else: 1226 party.ping = result 1227 1228 # Need to re-sort the list and update the row display. 1229 party.clean_display_index = None 1230 self._party_lists_dirty = True 1231 1232 def _fetch_local_addr_cb(self, val: str) -> None: 1233 self._local_address = str(val) 1234 1235 def _on_public_party_accessible_response( 1236 self, data: dict[str, Any] | None 1237 ) -> None: 1238 1239 # If we've got status text widgets, update them. 1240 text = self._host_status_text 1241 if text: 1242 if data is None: 1243 ba.textwidget( 1244 edit=text, 1245 text=ba.Lstr( 1246 resource='gatherWindow.' 'partyStatusNoConnectionText' 1247 ), 1248 color=(1, 0, 0), 1249 ) 1250 else: 1251 if not data.get('accessible', False): 1252 ex_line: str | ba.Lstr 1253 if self._local_address is not None: 1254 ex_line = ba.Lstr( 1255 value='\n${A} ${B}', 1256 subs=[ 1257 ( 1258 '${A}', 1259 ba.Lstr( 1260 resource='gatherWindow.' 1261 'manualYourLocalAddressText' 1262 ), 1263 ), 1264 ('${B}', self._local_address), 1265 ], 1266 ) 1267 else: 1268 ex_line = '' 1269 ba.textwidget( 1270 edit=text, 1271 text=ba.Lstr( 1272 value='${A}\n${B}${C}', 1273 subs=[ 1274 ( 1275 '${A}', 1276 ba.Lstr( 1277 resource='gatherWindow.' 1278 'partyStatusNotJoinableText' 1279 ), 1280 ), 1281 ( 1282 '${B}', 1283 ba.Lstr( 1284 resource='gatherWindow.' 1285 'manualRouterForwardingText', 1286 subs=[ 1287 ( 1288 '${PORT}', 1289 str( 1290 ba.internal.get_game_port() 1291 ), 1292 ) 1293 ], 1294 ), 1295 ), 1296 ('${C}', ex_line), 1297 ], 1298 ), 1299 color=(1, 0, 0), 1300 ) 1301 else: 1302 ba.textwidget( 1303 edit=text, 1304 text=ba.Lstr( 1305 resource='gatherWindow.' 'partyStatusJoinableText' 1306 ), 1307 color=(0, 1, 0), 1308 ) 1309 1310 def _do_status_check(self) -> None: 1311 from ba.internal import master_server_get 1312 1313 ba.textwidget( 1314 edit=self._host_status_text, 1315 color=(1, 1, 0), 1316 text=ba.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), 1317 ) 1318 master_server_get( 1319 'bsAccessCheck', 1320 {'b': ba.app.build_number}, 1321 callback=ba.WeakCall(self._on_public_party_accessible_response), 1322 ) 1323 1324 def _on_start_advertizing_press(self) -> None: 1325 from bastd.ui.account import show_sign_in_prompt 1326 1327 if ba.internal.get_v1_account_state() != 'signed_in': 1328 show_sign_in_prompt() 1329 return 1330 1331 name = cast(str, ba.textwidget(query=self._host_name_text)) 1332 if name == '': 1333 ba.screenmessage( 1334 ba.Lstr(resource='internal.invalidNameErrorText'), 1335 color=(1, 0, 0), 1336 ) 1337 ba.playsound(ba.getsound('error')) 1338 return 1339 ba.internal.set_public_party_name(name) 1340 cfg = ba.app.config 1341 cfg['Public Party Name'] = name 1342 cfg.commit() 1343 ba.playsound(ba.getsound('shieldUp')) 1344 ba.internal.set_public_party_enabled(True) 1345 1346 # In GUI builds we want to authenticate clients only when hosting 1347 # public parties. 1348 ba.internal.set_authenticate_clients(True) 1349 1350 self._do_status_check() 1351 ba.buttonwidget( 1352 edit=self._host_toggle_button, 1353 label=ba.Lstr( 1354 resource='gatherWindow.makePartyPrivateText', 1355 fallback_resource='gatherWindow.stopAdvertisingText', 1356 ), 1357 on_activate_call=self._on_stop_advertising_press, 1358 ) 1359 1360 def _on_stop_advertising_press(self) -> None: 1361 ba.internal.set_public_party_enabled(False) 1362 1363 # In GUI builds we want to authenticate clients only when hosting 1364 # public parties. 1365 ba.internal.set_authenticate_clients(False) 1366 ba.playsound(ba.getsound('shieldDown')) 1367 text = self._host_status_text 1368 if text: 1369 ba.textwidget( 1370 edit=text, 1371 text=ba.Lstr( 1372 resource='gatherWindow.' 'partyStatusNotPublicText' 1373 ), 1374 color=(0.6, 0.6, 0.6), 1375 ) 1376 ba.buttonwidget( 1377 edit=self._host_toggle_button, 1378 label=ba.Lstr( 1379 resource='gatherWindow.makePartyPublicText', 1380 fallback_resource='gatherWindow.startAdvertisingText', 1381 ), 1382 on_activate_call=self._on_start_advertizing_press, 1383 ) 1384 1385 def on_public_party_activate(self, party: PartyEntry) -> None: 1386 """Called when a party is clicked or otherwise activated.""" 1387 self.save_state() 1388 if party.queue is not None: 1389 from bastd.ui.partyqueue import PartyQueueWindow 1390 1391 ba.playsound(ba.getsound('swish')) 1392 PartyQueueWindow(party.queue, party.address, party.port) 1393 else: 1394 address = party.address 1395 port = party.port 1396 1397 # Rate limit this a bit. 1398 now = time.time() 1399 last_connect_time = self._last_connect_attempt_time 1400 if last_connect_time is None or now - last_connect_time > 2.0: 1401 ba.internal.connect_to_party(address, port=port) 1402 self._last_connect_attempt_time = now 1403 1404 def set_public_party_selection(self, sel: Selection) -> None: 1405 """Set the sel.""" 1406 if self._refreshing_list: 1407 return 1408 self._selection = sel 1409 self._have_user_selected_row = True 1410 1411 def _on_max_public_party_size_minus_press(self) -> None: 1412 val = max(1, ba.internal.get_public_party_max_size() - 1) 1413 ba.internal.set_public_party_max_size(val) 1414 ba.textwidget(edit=self._host_max_party_size_value, text=str(val)) 1415 1416 def _on_max_public_party_size_plus_press(self) -> None: 1417 val = ba.internal.get_public_party_max_size() 1418 val += 1 1419 ba.internal.set_public_party_max_size(val) 1420 ba.textwidget(edit=self._host_max_party_size_value, text=str(val))
The public tab in the gather UI
340 def __init__(self, window: GatherWindow) -> None: 341 super().__init__(window) 342 self._container: ba.Widget | None = None 343 self._join_text: ba.Widget | None = None 344 self._host_text: ba.Widget | None = None 345 self._filter_text: ba.Widget | None = None 346 self._local_address: str | None = None 347 self._last_connect_attempt_time: float | None = None 348 self._sub_tab: SubTabType = SubTabType.JOIN 349 self._selection: Selection | None = None 350 self._refreshing_list = False 351 self._update_timer: ba.Timer | None = None 352 self._host_scrollwidget: ba.Widget | None = None 353 self._host_name_text: ba.Widget | None = None 354 self._host_toggle_button: ba.Widget | None = None 355 self._last_server_list_query_time: float | None = None 356 self._join_list_column: ba.Widget | None = None 357 self._join_status_text: ba.Widget | None = None 358 self._host_max_party_size_value: ba.Widget | None = None 359 self._host_max_party_size_minus_button: (ba.Widget | None) = None 360 self._host_max_party_size_plus_button: (ba.Widget | None) = None 361 self._host_status_text: ba.Widget | None = None 362 self._signed_in = False 363 self._ui_rows: list[UIRow] = [] 364 self._refresh_ui_row = 0 365 self._have_user_selected_row = False 366 self._first_valid_server_list_time: float | None = None 367 368 # Parties indexed by id: 369 self._parties: dict[str, PartyEntry] = {} 370 371 # Parties sorted in display order: 372 self._parties_sorted: list[tuple[str, PartyEntry]] = [] 373 self._party_lists_dirty = True 374 375 # Sorted parties with filter applied: 376 self._parties_displayed: dict[str, PartyEntry] = {} 377 378 self._next_entry_index = 0 379 self._have_server_list_response = False 380 self._have_valid_server_list = False 381 self._filter_value = '' 382 self._pending_party_infos: list[dict[str, Any]] = [] 383 self._last_sub_scroll_height = 0.0
385 def on_activate( 386 self, 387 parent_widget: ba.Widget, 388 tab_button: ba.Widget, 389 region_width: float, 390 region_height: float, 391 region_left: float, 392 region_bottom: float, 393 ) -> ba.Widget: 394 c_width = region_width 395 c_height = region_height - 20 396 self._container = ba.containerwidget( 397 parent=parent_widget, 398 position=( 399 region_left, 400 region_bottom + (region_height - c_height) * 0.5, 401 ), 402 size=(c_width, c_height), 403 background=False, 404 selection_loops_to_parent=True, 405 ) 406 v = c_height - 30 407 self._join_text = ba.textwidget( 408 parent=self._container, 409 position=(c_width * 0.5 - 245, v - 13), 410 color=(0.6, 1.0, 0.6), 411 scale=1.3, 412 size=(200, 30), 413 maxwidth=250, 414 h_align='left', 415 v_align='center', 416 click_activate=True, 417 selectable=True, 418 autoselect=True, 419 on_activate_call=lambda: self._set_sub_tab( 420 SubTabType.JOIN, 421 region_width, 422 region_height, 423 playsound=True, 424 ), 425 text=ba.Lstr( 426 resource='gatherWindow.' 'joinPublicPartyDescriptionText' 427 ), 428 ) 429 self._host_text = ba.textwidget( 430 parent=self._container, 431 position=(c_width * 0.5 + 45, v - 13), 432 color=(0.6, 1.0, 0.6), 433 scale=1.3, 434 size=(200, 30), 435 maxwidth=250, 436 h_align='left', 437 v_align='center', 438 click_activate=True, 439 selectable=True, 440 autoselect=True, 441 on_activate_call=lambda: self._set_sub_tab( 442 SubTabType.HOST, 443 region_width, 444 region_height, 445 playsound=True, 446 ), 447 text=ba.Lstr( 448 resource='gatherWindow.' 'hostPublicPartyDescriptionText' 449 ), 450 ) 451 ba.widget(edit=self._join_text, up_widget=tab_button) 452 ba.widget( 453 edit=self._host_text, 454 left_widget=self._join_text, 455 up_widget=tab_button, 456 ) 457 ba.widget(edit=self._join_text, right_widget=self._host_text) 458 459 # Attempt to fetch our local address so we have it for error messages. 460 if self._local_address is None: 461 AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start() 462 463 self._set_sub_tab(self._sub_tab, region_width, region_height) 464 self._update_timer = ba.Timer( 465 0.1, 466 ba.WeakCall(self._update), 467 repeat=True, 468 timetype=ba.TimeType.REAL, 469 ) 470 return self._container
Called when the tab becomes the active one.
The tab should create and return a container widget covering the specified region.
475 def save_state(self) -> None: 476 477 # Save off a small number of parties with the lowest ping; we'll 478 # display these immediately when our UI comes back up which should 479 # be enough to make things feel nice and crisp while we do a full 480 # server re-query or whatnot. 481 ba.app.ui.window_states[type(self)] = State( 482 sub_tab=self._sub_tab, 483 parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], 484 next_entry_index=self._next_entry_index, 485 filter_value=self._filter_value, 486 have_server_list_response=self._have_server_list_response, 487 have_valid_server_list=self._have_valid_server_list, 488 )
Called when the parent window is saving state.
490 def restore_state(self) -> None: 491 state = ba.app.ui.window_states.get(type(self)) 492 if state is None: 493 state = State() 494 assert isinstance(state, State) 495 self._sub_tab = state.sub_tab 496 497 # Restore the parties we stored. 498 if state.parties: 499 self._parties = { 500 key: copy.copy(party) for key, party in state.parties 501 } 502 self._parties_sorted = list(self._parties.items()) 503 self._party_lists_dirty = True 504 505 self._next_entry_index = state.next_entry_index 506 507 # FIXME: should save/restore these too?.. 508 self._have_server_list_response = state.have_server_list_response 509 self._have_valid_server_list = state.have_valid_server_list 510 self._filter_value = state.filter_value
Called when the parent window is restoring state.
1385 def on_public_party_activate(self, party: PartyEntry) -> None: 1386 """Called when a party is clicked or otherwise activated.""" 1387 self.save_state() 1388 if party.queue is not None: 1389 from bastd.ui.partyqueue import PartyQueueWindow 1390 1391 ba.playsound(ba.getsound('swish')) 1392 PartyQueueWindow(party.queue, party.address, party.port) 1393 else: 1394 address = party.address 1395 port = party.port 1396 1397 # Rate limit this a bit. 1398 now = time.time() 1399 last_connect_time = self._last_connect_attempt_time 1400 if last_connect_time is None or now - last_connect_time > 2.0: 1401 ba.internal.connect_to_party(address, port=port) 1402 self._last_connect_attempt_time = now
Called when a party is clicked or otherwise activated.
1404 def set_public_party_selection(self, sel: Selection) -> None: 1405 """Set the sel.""" 1406 if self._refreshing_list: 1407 return 1408 self._selection = sel 1409 self._have_user_selected_row = True
Set the sel.