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))
class SubTabType(enum.Enum):
29class SubTabType(Enum):
30    """Available sub-tabs."""
31
32    JOIN = 'join'
33    HOST = 'host'

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class PartyEntry:
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.

PartyEntry( address: str, index: int, queue: str | None = None, port: int = -1, name: str = '', size: int = -1, size_max: int = -1, claimed: bool = False, ping: float | None = None, ping_interval: float = -1.0, next_ping_time: float = -1.0, ping_attempts: int = 0, ping_responses: int = 0, stats_addr: str | None = None, clean_display_index: int | None = None)
def get_key(self) -> str:
56    def get_key(self) -> str:
57        """Return the key used to store this party."""
58        return f'{self.address}_{self.port}'

Return the key used to store this party.

class UIRow:
 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.

UIRow()
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
def update( self, index: int, party: bastd.ui.gather.publictab.PartyEntry, sub_scroll_width: float, sub_scroll_height: float, lineheight: float, columnwidget: _ba.Widget, join_text: _ba.Widget, filter_text: _ba.Widget, existing_selection: bastd.ui.gather.publictab.Selection | None, tab: bastd.ui.gather.publictab.PublicGatherTab) -> None:
 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.

@dataclass
class State:
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.

State( sub_tab: bastd.ui.gather.publictab.SubTabType = <SubTabType.JOIN: 'join'>, parties: list[tuple[str, bastd.ui.gather.publictab.PartyEntry]] | None = None, next_entry_index: int = 0, filter_value: str = '', have_server_list_response: bool = False, have_valid_server_list: bool = False)
class SelectionComponent(enum.Enum):
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.

NAME = <SelectionComponent.NAME: 'name'>
STATS_BUTTON = <SelectionComponent.STATS_BUTTON: 'stats_button'>
Inherited Members
enum.Enum
name
value
@dataclass
class Selection:
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.

Selection( entry_key: str, component: bastd.ui.gather.publictab.SelectionComponent)
class AddrFetchThread(threading.Thread):
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.

AddrFetchThread(call: Callable[[Any], Any])
242    def __init__(self, call: Callable[[Any], Any]):
243        super().__init__()
244        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.

def run(self) -> None:
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
class PingThread(threading.Thread):
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.

PingThread( address: str, port: int, call: Callable[[str, int, float | None], int | None])
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.

def run(self) -> None:
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
class PublicGatherTab(bastd.ui.gather.GatherTab):
 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

PublicGatherTab(window: bastd.ui.gather.GatherWindow)
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
def on_activate( self, parent_widget: _ba.Widget, tab_button: _ba.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float) -> _ba.Widget:
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.

def on_deactivate(self) -> None:
472    def on_deactivate(self) -> None:
473        self._update_timer = None

Called when the tab will no longer be the active one.

def save_state(self) -> None:
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.

def restore_state(self) -> None:
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.

def on_public_party_activate(self, party: bastd.ui.gather.publictab.PartyEntry) -> None:
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.

def set_public_party_selection(self, sel: bastd.ui.gather.publictab.Selection) -> None:
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.

Inherited Members
bastd.ui.gather.GatherTab
window