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

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class PartyEntry:
35@dataclass
36class PartyEntry:
37    """Info about a public party."""
38    address: str
39    index: int
40    queue: str | None = None
41    port: int = -1
42    name: str = ''
43    size: int = -1
44    size_max: int = -1
45    claimed: bool = False
46    ping: float | None = None
47    ping_interval: float = -1.0
48    next_ping_time: float = -1.0
49    ping_attempts: int = 0
50    ping_responses: int = 0
51    stats_addr: str | None = None
52    clean_display_index: int | None = None
53
54    def get_key(self) -> str:
55        """Return the key used to store this party."""
56        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)
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:
54    def get_key(self) -> str:
55        """Return the key used to store this party."""
56        return f'{self.address}_{self.port}'

Return the key used to store this party.

class UIRow:
 59class UIRow:
 60    """Wrangles UI for a row in the party list."""
 61
 62    def __init__(self) -> None:
 63        self._name_widget: ba.Widget | None = None
 64        self._size_widget: ba.Widget | None = None
 65        self._ping_widget: ba.Widget | None = None
 66        self._stats_button: ba.Widget | None = None
 67
 68    def __del__(self) -> None:
 69        self._clear()
 70
 71    def _clear(self) -> None:
 72        for widget in [
 73                self._name_widget, self._size_widget, self._ping_widget,
 74                self._stats_button
 75        ]:
 76            if widget:
 77                widget.delete()
 78
 79    def update(self, index: int, party: PartyEntry, sub_scroll_width: float,
 80               sub_scroll_height: float, lineheight: float,
 81               columnwidget: ba.Widget, join_text: ba.Widget,
 82               filter_text: ba.Widget, existing_selection: Selection | None,
 83               tab: PublicGatherTab) -> None:
 84        """Update for the given data."""
 85        # pylint: disable=too-many-locals
 86
 87        # Quick-out: if we've been marked clean for a certain index and
 88        # we're still at that index, we're done.
 89        if party.clean_display_index == index:
 90            return
 91
 92        ping_good = _ba.get_v1_account_misc_read_val('pingGood', 100)
 93        ping_med = _ba.get_v1_account_misc_read_val('pingMed', 500)
 94
 95        self._clear()
 96        hpos = 20
 97        vpos = sub_scroll_height - lineheight * index - 50
 98        self._name_widget = ba.textwidget(
 99            text=ba.Lstr(value=party.name),
100            parent=columnwidget,
101            size=(sub_scroll_width * 0.63, 20),
102            position=(0 + hpos, 4 + vpos),
103            selectable=True,
104            on_select_call=ba.WeakCall(
105                tab.set_public_party_selection,
106                Selection(party.get_key(), SelectionComponent.NAME)),
107            on_activate_call=ba.WeakCall(tab.on_public_party_activate, party),
108            click_activate=True,
109            maxwidth=sub_scroll_width * 0.45,
110            corner_scale=1.4,
111            autoselect=True,
112            color=(1, 1, 1, 0.3 if party.ping is None else 1.0),
113            h_align='left',
114            v_align='center')
115        ba.widget(edit=self._name_widget,
116                  left_widget=join_text,
117                  show_buffer_top=64.0,
118                  show_buffer_bottom=64.0)
119        if existing_selection == Selection(party.get_key(),
120                                           SelectionComponent.NAME):
121            ba.containerwidget(edit=columnwidget,
122                               selected_child=self._name_widget)
123        if party.stats_addr:
124            url = party.stats_addr.replace(
125                '${ACCOUNT}',
126                _ba.get_v1_account_misc_read_val_2('resolvedAccountID',
127                                                   'UNKNOWN'))
128            self._stats_button = ba.buttonwidget(
129                color=(0.3, 0.6, 0.94),
130                textcolor=(1.0, 1.0, 1.0),
131                label=ba.Lstr(resource='statsText'),
132                parent=columnwidget,
133                autoselect=True,
134                on_activate_call=ba.Call(ba.open_url, url),
135                on_select_call=ba.WeakCall(
136                    tab.set_public_party_selection,
137                    Selection(party.get_key(),
138                              SelectionComponent.STATS_BUTTON)),
139                size=(120, 40),
140                position=(sub_scroll_width * 0.66 + hpos, 1 + vpos),
141                scale=0.9)
142            if existing_selection == Selection(
143                    party.get_key(), SelectionComponent.STATS_BUTTON):
144                ba.containerwidget(edit=columnwidget,
145                                   selected_child=self._stats_button)
146
147        self._size_widget = ba.textwidget(
148            text=str(party.size) + '/' + str(party.size_max),
149            parent=columnwidget,
150            size=(0, 0),
151            position=(sub_scroll_width * 0.86 + hpos, 20 + vpos),
152            scale=0.7,
153            color=(0.8, 0.8, 0.8),
154            h_align='right',
155            v_align='center')
156
157        if index == 0:
158            ba.widget(edit=self._name_widget, up_widget=filter_text)
159            if self._stats_button:
160                ba.widget(edit=self._stats_button, up_widget=filter_text)
161
162        self._ping_widget = ba.textwidget(parent=columnwidget,
163                                          size=(0, 0),
164                                          position=(sub_scroll_width * 0.94 +
165                                                    hpos, 20 + vpos),
166                                          scale=0.7,
167                                          h_align='right',
168                                          v_align='center')
169        if party.ping is None:
170            ba.textwidget(edit=self._ping_widget,
171                          text='-',
172                          color=(0.5, 0.5, 0.5))
173        else:
174            ba.textwidget(edit=self._ping_widget,
175                          text=str(int(party.ping)),
176                          color=(0, 1, 0) if party.ping <= ping_good else
177                          (1, 1, 0) if party.ping <= ping_med else (1, 0, 0))
178
179        party.clean_display_index = index

Wrangles UI for a row in the party list.

UIRow()
62    def __init__(self) -> None:
63        self._name_widget: ba.Widget | None = None
64        self._size_widget: ba.Widget | None = None
65        self._ping_widget: ba.Widget | None = None
66        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:
 79    def update(self, index: int, party: PartyEntry, sub_scroll_width: float,
 80               sub_scroll_height: float, lineheight: float,
 81               columnwidget: ba.Widget, join_text: ba.Widget,
 82               filter_text: ba.Widget, existing_selection: Selection | None,
 83               tab: PublicGatherTab) -> None:
 84        """Update for the given data."""
 85        # pylint: disable=too-many-locals
 86
 87        # Quick-out: if we've been marked clean for a certain index and
 88        # we're still at that index, we're done.
 89        if party.clean_display_index == index:
 90            return
 91
 92        ping_good = _ba.get_v1_account_misc_read_val('pingGood', 100)
 93        ping_med = _ba.get_v1_account_misc_read_val('pingMed', 500)
 94
 95        self._clear()
 96        hpos = 20
 97        vpos = sub_scroll_height - lineheight * index - 50
 98        self._name_widget = ba.textwidget(
 99            text=ba.Lstr(value=party.name),
100            parent=columnwidget,
101            size=(sub_scroll_width * 0.63, 20),
102            position=(0 + hpos, 4 + vpos),
103            selectable=True,
104            on_select_call=ba.WeakCall(
105                tab.set_public_party_selection,
106                Selection(party.get_key(), SelectionComponent.NAME)),
107            on_activate_call=ba.WeakCall(tab.on_public_party_activate, party),
108            click_activate=True,
109            maxwidth=sub_scroll_width * 0.45,
110            corner_scale=1.4,
111            autoselect=True,
112            color=(1, 1, 1, 0.3 if party.ping is None else 1.0),
113            h_align='left',
114            v_align='center')
115        ba.widget(edit=self._name_widget,
116                  left_widget=join_text,
117                  show_buffer_top=64.0,
118                  show_buffer_bottom=64.0)
119        if existing_selection == Selection(party.get_key(),
120                                           SelectionComponent.NAME):
121            ba.containerwidget(edit=columnwidget,
122                               selected_child=self._name_widget)
123        if party.stats_addr:
124            url = party.stats_addr.replace(
125                '${ACCOUNT}',
126                _ba.get_v1_account_misc_read_val_2('resolvedAccountID',
127                                                   'UNKNOWN'))
128            self._stats_button = ba.buttonwidget(
129                color=(0.3, 0.6, 0.94),
130                textcolor=(1.0, 1.0, 1.0),
131                label=ba.Lstr(resource='statsText'),
132                parent=columnwidget,
133                autoselect=True,
134                on_activate_call=ba.Call(ba.open_url, url),
135                on_select_call=ba.WeakCall(
136                    tab.set_public_party_selection,
137                    Selection(party.get_key(),
138                              SelectionComponent.STATS_BUTTON)),
139                size=(120, 40),
140                position=(sub_scroll_width * 0.66 + hpos, 1 + vpos),
141                scale=0.9)
142            if existing_selection == Selection(
143                    party.get_key(), SelectionComponent.STATS_BUTTON):
144                ba.containerwidget(edit=columnwidget,
145                                   selected_child=self._stats_button)
146
147        self._size_widget = ba.textwidget(
148            text=str(party.size) + '/' + str(party.size_max),
149            parent=columnwidget,
150            size=(0, 0),
151            position=(sub_scroll_width * 0.86 + hpos, 20 + vpos),
152            scale=0.7,
153            color=(0.8, 0.8, 0.8),
154            h_align='right',
155            v_align='center')
156
157        if index == 0:
158            ba.widget(edit=self._name_widget, up_widget=filter_text)
159            if self._stats_button:
160                ba.widget(edit=self._stats_button, up_widget=filter_text)
161
162        self._ping_widget = ba.textwidget(parent=columnwidget,
163                                          size=(0, 0),
164                                          position=(sub_scroll_width * 0.94 +
165                                                    hpos, 20 + vpos),
166                                          scale=0.7,
167                                          h_align='right',
168                                          v_align='center')
169        if party.ping is None:
170            ba.textwidget(edit=self._ping_widget,
171                          text='-',
172                          color=(0.5, 0.5, 0.5))
173        else:
174            ba.textwidget(edit=self._ping_widget,
175                          text=str(int(party.ping)),
176                          color=(0, 1, 0) if party.ping <= ping_good else
177                          (1, 1, 0) if party.ping <= ping_med else (1, 0, 0))
178
179        party.clean_display_index = index

Update for the given data.

@dataclass
class State:
182@dataclass
183class State:
184    """State saved/restored only while the app is running."""
185    sub_tab: SubTabType = SubTabType.JOIN
186    parties: list[tuple[str, PartyEntry]] | None = None
187    next_entry_index: int = 0
188    filter_value: str = ''
189    have_server_list_response: bool = False
190    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)
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):
193class SelectionComponent(Enum):
194    """Describes what part of an entry is selected."""
195    NAME = 'name'
196    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:
199@dataclass
200class Selection:
201    """Describes the currently selected list element."""
202    entry_key: str
203    component: SelectionComponent

Describes the currently selected list element.

Selection( entry_key: str, component: bastd.ui.gather.publictab.SelectionComponent)
class AddrFetchThread(threading.Thread):
206class AddrFetchThread(threading.Thread):
207    """Thread for fetching an address in the bg."""
208
209    def __init__(self, call: Callable[[Any], Any]):
210        super().__init__()
211        self._call = call
212
213    def run(self) -> None:
214        try:
215            # FIXME: Update this to work with IPv6 at some point.
216            import socket
217            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
218            sock.connect(('8.8.8.8', 80))
219            val = sock.getsockname()[0]
220            sock.close()
221            ba.pushcall(ba.Call(self._call, val), from_other_thread=True)
222        except Exception as exc:
223            from efro.error import is_udp_communication_error
224            # Ignore expected network errors; log others.
225            if is_udp_communication_error(exc):
226                pass
227            else:
228                ba.print_exception()

Thread for fetching an address in the bg.

AddrFetchThread(call: Callable[[Any], Any])
209    def __init__(self, call: Callable[[Any], Any]):
210        super().__init__()
211        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:
213    def run(self) -> None:
214        try:
215            # FIXME: Update this to work with IPv6 at some point.
216            import socket
217            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
218            sock.connect(('8.8.8.8', 80))
219            val = sock.getsockname()[0]
220            sock.close()
221            ba.pushcall(ba.Call(self._call, val), from_other_thread=True)
222        except Exception as exc:
223            from efro.error import is_udp_communication_error
224            # Ignore expected network errors; log others.
225            if is_udp_communication_error(exc):
226                pass
227            else:
228                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):
231class PingThread(threading.Thread):
232    """Thread for sending out game pings."""
233
234    def __init__(self, address: str, port: int,
235                 call: Callable[[str, int, float | None], int | None]):
236        super().__init__()
237        self._address = address
238        self._port = port
239        self._call = call
240
241    def run(self) -> None:
242        ba.app.ping_thread_count += 1
243        sock: socket.socket | None = None
244        try:
245            import socket
246            from ba.internal import get_ip_address_type
247            socket_type = get_ip_address_type(self._address)
248            sock = socket.socket(socket_type, socket.SOCK_DGRAM)
249            sock.connect((self._address, self._port))
250
251            accessible = False
252            starttime = time.time()
253
254            # Send a few pings and wait a second for
255            # a response.
256            sock.settimeout(1)
257            for _i in range(3):
258                sock.send(b'\x0b')
259                result: bytes | None
260                try:
261                    # 11: BA_PACKET_SIMPLE_PING
262                    result = sock.recv(10)
263                except Exception:
264                    result = None
265                if result == b'\x0c':
266                    # 12: BA_PACKET_SIMPLE_PONG
267                    accessible = True
268                    break
269                time.sleep(1)
270            ping = (time.time() - starttime) * 1000.0
271            ba.pushcall(ba.Call(self._call, self._address, self._port,
272                                ping if accessible else None),
273                        from_other_thread=True)
274        except Exception as exc:
275            from efro.error import is_udp_communication_error
276            if is_udp_communication_error(exc):
277                pass
278            else:
279                ba.print_exception('Error on gather ping', once=True)
280        finally:
281            try:
282                if sock is not None:
283                    sock.close()
284            except Exception:
285                ba.print_exception('Error on gather ping cleanup', once=True)
286
287        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])
234    def __init__(self, address: str, port: int,
235                 call: Callable[[str, int, float | None], int | None]):
236        super().__init__()
237        self._address = address
238        self._port = port
239        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:
241    def run(self) -> None:
242        ba.app.ping_thread_count += 1
243        sock: socket.socket | None = None
244        try:
245            import socket
246            from ba.internal import get_ip_address_type
247            socket_type = get_ip_address_type(self._address)
248            sock = socket.socket(socket_type, socket.SOCK_DGRAM)
249            sock.connect((self._address, self._port))
250
251            accessible = False
252            starttime = time.time()
253
254            # Send a few pings and wait a second for
255            # a response.
256            sock.settimeout(1)
257            for _i in range(3):
258                sock.send(b'\x0b')
259                result: bytes | None
260                try:
261                    # 11: BA_PACKET_SIMPLE_PING
262                    result = sock.recv(10)
263                except Exception:
264                    result = None
265                if result == b'\x0c':
266                    # 12: BA_PACKET_SIMPLE_PONG
267                    accessible = True
268                    break
269                time.sleep(1)
270            ping = (time.time() - starttime) * 1000.0
271            ba.pushcall(ba.Call(self._call, self._address, self._port,
272                                ping if accessible else None),
273                        from_other_thread=True)
274        except Exception as exc:
275            from efro.error import is_udp_communication_error
276            if is_udp_communication_error(exc):
277                pass
278            else:
279                ba.print_exception('Error on gather ping', once=True)
280        finally:
281            try:
282                if sock is not None:
283                    sock.close()
284            except Exception:
285                ba.print_exception('Error on gather ping cleanup', once=True)
286
287        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):
 290class PublicGatherTab(GatherTab):
 291    """The public tab in the gather UI"""
 292
 293    def __init__(self, window: GatherWindow) -> None:
 294        super().__init__(window)
 295        self._container: ba.Widget | None = None
 296        self._join_text: ba.Widget | None = None
 297        self._host_text: ba.Widget | None = None
 298        self._filter_text: ba.Widget | None = None
 299        self._local_address: str | None = None
 300        self._last_connect_attempt_time: float | None = None
 301        self._sub_tab: SubTabType = SubTabType.JOIN
 302        self._selection: Selection | None = None
 303        self._refreshing_list = False
 304        self._update_timer: ba.Timer | None = None
 305        self._host_scrollwidget: ba.Widget | None = None
 306        self._host_name_text: ba.Widget | None = None
 307        self._host_toggle_button: ba.Widget | None = None
 308        self._last_server_list_query_time: float | None = None
 309        self._join_list_column: ba.Widget | None = None
 310        self._join_status_text: ba.Widget | None = None
 311        self._host_max_party_size_value: ba.Widget | None = None
 312        self._host_max_party_size_minus_button: (ba.Widget | None) = None
 313        self._host_max_party_size_plus_button: (ba.Widget | None) = None
 314        self._host_status_text: ba.Widget | None = None
 315        self._signed_in = False
 316        self._ui_rows: list[UIRow] = []
 317        self._refresh_ui_row = 0
 318        self._have_user_selected_row = False
 319        self._first_valid_server_list_time: float | None = None
 320
 321        # Parties indexed by id:
 322        self._parties: dict[str, PartyEntry] = {}
 323
 324        # Parties sorted in display order:
 325        self._parties_sorted: list[tuple[str, PartyEntry]] = []
 326        self._party_lists_dirty = True
 327
 328        # Sorted parties with filter applied:
 329        self._parties_displayed: dict[str, PartyEntry] = {}
 330
 331        self._next_entry_index = 0
 332        self._have_server_list_response = False
 333        self._have_valid_server_list = False
 334        self._filter_value = ''
 335        self._pending_party_infos: list[dict[str, Any]] = []
 336        self._last_sub_scroll_height = 0.0
 337
 338    def on_activate(
 339        self,
 340        parent_widget: ba.Widget,
 341        tab_button: ba.Widget,
 342        region_width: float,
 343        region_height: float,
 344        region_left: float,
 345        region_bottom: float,
 346    ) -> ba.Widget:
 347        c_width = region_width
 348        c_height = region_height - 20
 349        self._container = ba.containerwidget(
 350            parent=parent_widget,
 351            position=(region_left,
 352                      region_bottom + (region_height - c_height) * 0.5),
 353            size=(c_width, c_height),
 354            background=False,
 355            selection_loops_to_parent=True)
 356        v = c_height - 30
 357        self._join_text = ba.textwidget(
 358            parent=self._container,
 359            position=(c_width * 0.5 - 245, v - 13),
 360            color=(0.6, 1.0, 0.6),
 361            scale=1.3,
 362            size=(200, 30),
 363            maxwidth=250,
 364            h_align='left',
 365            v_align='center',
 366            click_activate=True,
 367            selectable=True,
 368            autoselect=True,
 369            on_activate_call=lambda: self._set_sub_tab(
 370                SubTabType.JOIN,
 371                region_width,
 372                region_height,
 373                playsound=True,
 374            ),
 375            text=ba.Lstr(resource='gatherWindow.'
 376                         'joinPublicPartyDescriptionText'))
 377        self._host_text = ba.textwidget(
 378            parent=self._container,
 379            position=(c_width * 0.5 + 45, v - 13),
 380            color=(0.6, 1.0, 0.6),
 381            scale=1.3,
 382            size=(200, 30),
 383            maxwidth=250,
 384            h_align='left',
 385            v_align='center',
 386            click_activate=True,
 387            selectable=True,
 388            autoselect=True,
 389            on_activate_call=lambda: self._set_sub_tab(
 390                SubTabType.HOST,
 391                region_width,
 392                region_height,
 393                playsound=True,
 394            ),
 395            text=ba.Lstr(resource='gatherWindow.'
 396                         'hostPublicPartyDescriptionText'))
 397        ba.widget(edit=self._join_text, up_widget=tab_button)
 398        ba.widget(edit=self._host_text,
 399                  left_widget=self._join_text,
 400                  up_widget=tab_button)
 401        ba.widget(edit=self._join_text, right_widget=self._host_text)
 402
 403        # Attempt to fetch our local address so we have it for error messages.
 404        if self._local_address is None:
 405            AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start()
 406
 407        self._set_sub_tab(self._sub_tab, region_width, region_height)
 408        self._update_timer = ba.Timer(0.1,
 409                                      ba.WeakCall(self._update),
 410                                      repeat=True,
 411                                      timetype=ba.TimeType.REAL)
 412        return self._container
 413
 414    def on_deactivate(self) -> None:
 415        self._update_timer = None
 416
 417    def save_state(self) -> None:
 418
 419        # Save off a small number of parties with the lowest ping; we'll
 420        # display these immediately when our UI comes back up which should
 421        # be enough to make things feel nice and crisp while we do a full
 422        # server re-query or whatnot.
 423        ba.app.ui.window_states[type(self)] = State(
 424            sub_tab=self._sub_tab,
 425            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
 426            next_entry_index=self._next_entry_index,
 427            filter_value=self._filter_value,
 428            have_server_list_response=self._have_server_list_response,
 429            have_valid_server_list=self._have_valid_server_list)
 430
 431    def restore_state(self) -> None:
 432        state = ba.app.ui.window_states.get(type(self))
 433        if state is None:
 434            state = State()
 435        assert isinstance(state, State)
 436        self._sub_tab = state.sub_tab
 437
 438        # Restore the parties we stored.
 439        if state.parties:
 440            self._parties = {
 441                key: copy.copy(party)
 442                for key, party in state.parties
 443            }
 444            self._parties_sorted = list(self._parties.items())
 445            self._party_lists_dirty = True
 446
 447            self._next_entry_index = state.next_entry_index
 448
 449            # FIXME: should save/restore these too?..
 450            self._have_server_list_response = state.have_server_list_response
 451            self._have_valid_server_list = state.have_valid_server_list
 452        self._filter_value = state.filter_value
 453
 454    def _set_sub_tab(self,
 455                     value: SubTabType,
 456                     region_width: float,
 457                     region_height: float,
 458                     playsound: bool = False) -> None:
 459        assert self._container
 460        if playsound:
 461            ba.playsound(ba.getsound('click01'))
 462
 463        # Reset our selection.
 464        # (prevents selecting something way down the list if we switched away
 465        # and came back)
 466        self._selection = None
 467        self._have_user_selected_row = False
 468
 469        # Reset refresh to the top and make sure everything refreshes.
 470        self._refresh_ui_row = 0
 471        for party in self._parties.values():
 472            party.clean_display_index = None
 473
 474        self._sub_tab = value
 475        active_color = (0.6, 1.0, 0.6)
 476        inactive_color = (0.5, 0.4, 0.5)
 477        ba.textwidget(
 478            edit=self._join_text,
 479            color=active_color if value is SubTabType.JOIN else inactive_color)
 480        ba.textwidget(
 481            edit=self._host_text,
 482            color=active_color if value is SubTabType.HOST else inactive_color)
 483
 484        # Clear anything existing in the old sub-tab.
 485        for widget in self._container.get_children():
 486            if widget and widget not in {self._host_text, self._join_text}:
 487                widget.delete()
 488
 489        if value is SubTabType.JOIN:
 490            self._build_join_tab(region_width, region_height)
 491
 492        if value is SubTabType.HOST:
 493            self._build_host_tab(region_width, region_height)
 494
 495    def _build_join_tab(self, region_width: float,
 496                        region_height: float) -> None:
 497        c_width = region_width
 498        c_height = region_height - 20
 499        sub_scroll_height = c_height - 125
 500        sub_scroll_width = 830
 501        v = c_height - 35
 502        v -= 60
 503        filter_txt = ba.Lstr(resource='filterText')
 504        self._filter_text = ba.textwidget(parent=self._container,
 505                                          text=self._filter_value,
 506                                          size=(350, 45),
 507                                          position=(290, v - 10),
 508                                          h_align='left',
 509                                          v_align='center',
 510                                          editable=True,
 511                                          description=filter_txt)
 512        ba.widget(edit=self._filter_text, up_widget=self._join_text)
 513        ba.textwidget(text=filter_txt,
 514                      parent=self._container,
 515                      size=(0, 0),
 516                      position=(270, v + 13),
 517                      maxwidth=150,
 518                      scale=0.8,
 519                      color=(0.5, 0.46, 0.5),
 520                      flatness=1.0,
 521                      h_align='right',
 522                      v_align='center')
 523
 524        ba.textwidget(text=ba.Lstr(resource='nameText'),
 525                      parent=self._container,
 526                      size=(0, 0),
 527                      position=(90, v - 8),
 528                      maxwidth=60,
 529                      scale=0.6,
 530                      color=(0.5, 0.46, 0.5),
 531                      flatness=1.0,
 532                      h_align='center',
 533                      v_align='center')
 534        ba.textwidget(text=ba.Lstr(resource='gatherWindow.partySizeText'),
 535                      parent=self._container,
 536                      size=(0, 0),
 537                      position=(755, v - 8),
 538                      maxwidth=60,
 539                      scale=0.6,
 540                      color=(0.5, 0.46, 0.5),
 541                      flatness=1.0,
 542                      h_align='center',
 543                      v_align='center')
 544        ba.textwidget(text=ba.Lstr(resource='gatherWindow.pingText'),
 545                      parent=self._container,
 546                      size=(0, 0),
 547                      position=(825, v - 8),
 548                      maxwidth=60,
 549                      scale=0.6,
 550                      color=(0.5, 0.46, 0.5),
 551                      flatness=1.0,
 552                      h_align='center',
 553                      v_align='center')
 554        v -= sub_scroll_height + 23
 555        self._host_scrollwidget = scrollw = ba.scrollwidget(
 556            parent=self._container,
 557            simple_culling_v=10,
 558            position=((c_width - sub_scroll_width) * 0.5, v),
 559            size=(sub_scroll_width, sub_scroll_height),
 560            claims_up_down=False,
 561            claims_left_right=True,
 562            autoselect=True)
 563        self._join_list_column = ba.containerwidget(parent=scrollw,
 564                                                    background=False,
 565                                                    size=(400, 400),
 566                                                    claims_left_right=True)
 567        self._join_status_text = ba.textwidget(parent=self._container,
 568                                               text='',
 569                                               size=(0, 0),
 570                                               scale=0.9,
 571                                               flatness=1.0,
 572                                               shadow=0.0,
 573                                               h_align='center',
 574                                               v_align='top',
 575                                               maxwidth=c_width,
 576                                               color=(0.6, 0.6, 0.6),
 577                                               position=(c_width * 0.5,
 578                                                         c_height * 0.5))
 579
 580    def _build_host_tab(self, region_width: float,
 581                        region_height: float) -> None:
 582        c_width = region_width
 583        c_height = region_height - 20
 584        v = c_height - 35
 585        v -= 25
 586        is_public_enabled = _ba.get_public_party_enabled()
 587        v -= 30
 588
 589        ba.textwidget(
 590            parent=self._container,
 591            size=(0, 0),
 592            h_align='center',
 593            v_align='center',
 594            maxwidth=c_width * 0.9,
 595            scale=0.7,
 596            flatness=1.0,
 597            color=(0.5, 0.46, 0.5),
 598            position=(region_width * 0.5, v + 10),
 599            text=ba.Lstr(resource='gatherWindow.publicHostRouterConfigText'))
 600        v -= 30
 601
 602        party_name_text = ba.Lstr(
 603            resource='gatherWindow.partyNameText',
 604            fallback_resource='editGameListWindow.nameText')
 605        ba.textwidget(parent=self._container,
 606                      size=(0, 0),
 607                      h_align='right',
 608                      v_align='center',
 609                      maxwidth=200,
 610                      scale=0.8,
 611                      color=ba.app.ui.infotextcolor,
 612                      position=(210, v - 9),
 613                      text=party_name_text)
 614        self._host_name_text = ba.textwidget(parent=self._container,
 615                                             editable=True,
 616                                             size=(535, 40),
 617                                             position=(230, v - 30),
 618                                             text=ba.app.config.get(
 619                                                 'Public Party Name', ''),
 620                                             maxwidth=494,
 621                                             shadow=0.3,
 622                                             flatness=1.0,
 623                                             description=party_name_text,
 624                                             autoselect=True,
 625                                             v_align='center',
 626                                             corner_scale=1.0)
 627
 628        v -= 60
 629        ba.textwidget(parent=self._container,
 630                      size=(0, 0),
 631                      h_align='right',
 632                      v_align='center',
 633                      maxwidth=200,
 634                      scale=0.8,
 635                      color=ba.app.ui.infotextcolor,
 636                      position=(210, v - 9),
 637                      text=ba.Lstr(resource='maxPartySizeText',
 638                                   fallback_resource='maxConnectionsText'))
 639        self._host_max_party_size_value = ba.textwidget(
 640            parent=self._container,
 641            size=(0, 0),
 642            h_align='center',
 643            v_align='center',
 644            scale=1.2,
 645            color=(1, 1, 1),
 646            position=(240, v - 9),
 647            text=str(_ba.get_public_party_max_size()))
 648        btn1 = self._host_max_party_size_minus_button = (ba.buttonwidget(
 649            parent=self._container,
 650            size=(40, 40),
 651            on_activate_call=ba.WeakCall(
 652                self._on_max_public_party_size_minus_press),
 653            position=(280, v - 26),
 654            label='-',
 655            autoselect=True))
 656        btn2 = self._host_max_party_size_plus_button = (ba.buttonwidget(
 657            parent=self._container,
 658            size=(40, 40),
 659            on_activate_call=ba.WeakCall(
 660                self._on_max_public_party_size_plus_press),
 661            position=(350, v - 26),
 662            label='+',
 663            autoselect=True))
 664        v -= 50
 665        v -= 70
 666        if is_public_enabled:
 667            label = ba.Lstr(
 668                resource='gatherWindow.makePartyPrivateText',
 669                fallback_resource='gatherWindow.stopAdvertisingText')
 670        else:
 671            label = ba.Lstr(
 672                resource='gatherWindow.makePartyPublicText',
 673                fallback_resource='gatherWindow.startAdvertisingText')
 674        self._host_toggle_button = ba.buttonwidget(
 675            parent=self._container,
 676            label=label,
 677            size=(400, 80),
 678            on_activate_call=self._on_stop_advertising_press
 679            if is_public_enabled else self._on_start_advertizing_press,
 680            position=(c_width * 0.5 - 200, v),
 681            autoselect=True,
 682            up_widget=btn2)
 683        ba.widget(edit=self._host_name_text, down_widget=btn2)
 684        ba.widget(edit=btn2, up_widget=self._host_name_text)
 685        ba.widget(edit=btn1, up_widget=self._host_name_text)
 686        ba.widget(edit=self._join_text, down_widget=self._host_name_text)
 687        v -= 10
 688        self._host_status_text = ba.textwidget(
 689            parent=self._container,
 690            text=ba.Lstr(resource='gatherWindow.'
 691                         'partyStatusNotPublicText'),
 692            size=(0, 0),
 693            scale=0.7,
 694            flatness=1.0,
 695            h_align='center',
 696            v_align='top',
 697            maxwidth=c_width * 0.9,
 698            color=(0.6, 0.56, 0.6),
 699            position=(c_width * 0.5, v))
 700        v -= 90
 701        ba.textwidget(
 702            parent=self._container,
 703            text=ba.Lstr(resource='gatherWindow.dedicatedServerInfoText'),
 704            size=(0, 0),
 705            scale=0.7,
 706            flatness=1.0,
 707            h_align='center',
 708            v_align='center',
 709            maxwidth=c_width * 0.9,
 710            color=(0.5, 0.46, 0.5),
 711            position=(c_width * 0.5, v))
 712
 713        # If public sharing is already on,
 714        # launch a status-check immediately.
 715        if _ba.get_public_party_enabled():
 716            self._do_status_check()
 717
 718    def _on_public_party_query_result(self,
 719                                      result: dict[str, Any] | None) -> None:
 720        starttime = time.time()
 721        self._have_server_list_response = True
 722
 723        if result is None:
 724            self._have_valid_server_list = False
 725            return
 726
 727        if not self._have_valid_server_list:
 728            self._first_valid_server_list_time = time.time()
 729
 730        self._have_valid_server_list = True
 731        parties_in = result['l']
 732
 733        assert isinstance(parties_in, list)
 734        self._pending_party_infos += parties_in
 735
 736        # To avoid causing a stutter here, we do most processing of
 737        # these entries incrementally in our _update() method.
 738        # The one thing we do here is prune parties not contained in
 739        # this result.
 740        for partyval in list(self._parties.values()):
 741            partyval.claimed = False
 742        for party_in in parties_in:
 743            addr = party_in['a']
 744            assert isinstance(addr, str)
 745            port = party_in['p']
 746            assert isinstance(port, int)
 747            party_key = f'{addr}_{port}'
 748            party = self._parties.get(party_key)
 749            if party is not None:
 750                party.claimed = True
 751        self._parties = {
 752            key: val
 753            for key, val in list(self._parties.items()) if val.claimed
 754        }
 755        self._parties_sorted = [
 756            p for p in self._parties_sorted if p[1].claimed
 757        ]
 758        self._party_lists_dirty = True
 759
 760        # self._update_server_list()
 761        if DEBUG_PROCESSING:
 762            print(f'Handled public party query results in '
 763                  f'{time.time()-starttime:.5f}s.')
 764
 765    def _update(self) -> None:
 766        """Periodic updating."""
 767
 768        # Special case: if a party-queue window is up, don't do any of this
 769        # (keeps things smoother).
 770        # if ba.app.ui.have_party_queue_window:
 771        #     return
 772
 773        if self._sub_tab is SubTabType.JOIN:
 774
 775            # Keep our filter-text up to date from the UI.
 776            text = self._filter_text
 777            if text:
 778                filter_value = cast(str, ba.textwidget(query=text))
 779                if filter_value != self._filter_value:
 780                    self._filter_value = filter_value
 781                    self._party_lists_dirty = True
 782
 783                    # Also wipe out party clean-row states.
 784                    # (otherwise if a party disappears from a row due to
 785                    # filtering and then reappears on that same row when
 786                    # the filter is removed it may not update)
 787                    for party in self._parties.values():
 788                        party.clean_display_index = None
 789
 790            self._query_party_list_periodically()
 791            self._ping_parties_periodically()
 792
 793        # If any new party infos have come in, apply some of them.
 794        self._process_pending_party_infos()
 795
 796        # Anytime we sign in/out, make sure we refresh our list.
 797        signed_in = _ba.get_v1_account_state() == 'signed_in'
 798        if self._signed_in != signed_in:
 799            self._signed_in = signed_in
 800            self._party_lists_dirty = True
 801
 802        # Update sorting to account for ping updates, new parties, etc.
 803        self._update_party_lists()
 804
 805        # If we've got a party-name text widget, keep its value plugged
 806        # into our public host name.
 807        text = self._host_name_text
 808        if text:
 809            name = cast(str, ba.textwidget(query=self._host_name_text))
 810            _ba.set_public_party_name(name)
 811
 812        # Update status text.
 813        status_text = self._join_status_text
 814        if status_text:
 815            if not signed_in:
 816                ba.textwidget(edit=status_text,
 817                              text=ba.Lstr(resource='notSignedInText'))
 818            else:
 819                # If we have a valid list, show no status; just the list.
 820                # Otherwise show either 'loading...' or 'error' depending
 821                # on whether this is our first go-round.
 822                if self._have_valid_server_list:
 823                    ba.textwidget(edit=status_text, text='')
 824                else:
 825                    if self._have_server_list_response:
 826                        ba.textwidget(edit=status_text,
 827                                      text=ba.Lstr(resource='errorText'))
 828                    else:
 829                        ba.textwidget(
 830                            edit=status_text,
 831                            text=ba.Lstr(
 832                                value='${A}...',
 833                                subs=[('${A}',
 834                                       ba.Lstr(resource='store.loadingText'))],
 835                            ))
 836
 837        self._update_party_rows()
 838
 839    def _update_party_rows(self) -> None:
 840        columnwidget = self._join_list_column
 841        if not columnwidget:
 842            return
 843
 844        assert self._join_text
 845        assert self._filter_text
 846
 847        # Janky - allow escaping when there's nothing in our list.
 848        assert self._host_scrollwidget
 849        ba.containerwidget(edit=self._host_scrollwidget,
 850                           claims_up_down=(len(self._parties_displayed) > 0))
 851
 852        # Clip if we have more UI rows than parties to show.
 853        clipcount = len(self._ui_rows) - len(self._parties_displayed)
 854        if clipcount > 0:
 855            clipcount = max(clipcount, 50)
 856            self._ui_rows = self._ui_rows[:-clipcount]
 857
 858        # If we have no parties to show, we're done.
 859        if not self._parties_displayed:
 860            return
 861
 862        sub_scroll_width = 830
 863        lineheight = 42
 864        sub_scroll_height = lineheight * len(self._parties_displayed) + 50
 865        ba.containerwidget(edit=columnwidget,
 866                           size=(sub_scroll_width, sub_scroll_height))
 867
 868        # Any time our height changes, reset the refresh back to the top
 869        # so we don't see ugly empty spaces appearing during initial list
 870        # filling.
 871        if sub_scroll_height != self._last_sub_scroll_height:
 872            self._refresh_ui_row = 0
 873            self._last_sub_scroll_height = sub_scroll_height
 874
 875            # Also note that we need to redisplay everything since its pos
 876            # will have changed.. :(
 877            for party in self._parties.values():
 878                party.clean_display_index = None
 879
 880        # Ew; this rebuilding generates deferred selection callbacks
 881        # so we need to push deferred notices so we know to ignore them.
 882        def refresh_on() -> None:
 883            self._refreshing_list = True
 884
 885        ba.pushcall(refresh_on)
 886
 887        # Ok, now here's the deal: we want to avoid creating/updating this
 888        # entire list at one time because it will lead to hitches. So we
 889        # refresh individual rows quickly in a loop.
 890        rowcount = min(12, len(self._parties_displayed))
 891
 892        party_vals_displayed = list(self._parties_displayed.values())
 893        while rowcount > 0:
 894            refresh_row = self._refresh_ui_row % len(self._parties_displayed)
 895            if refresh_row >= len(self._ui_rows):
 896                self._ui_rows.append(UIRow())
 897                refresh_row = len(self._ui_rows) - 1
 898
 899            # For the first few seconds after getting our first server-list,
 900            # refresh only the top section of the list; this allows the lowest
 901            # ping servers to show up more quickly.
 902            if self._first_valid_server_list_time is not None:
 903                if time.time() - self._first_valid_server_list_time < 4.0:
 904                    if refresh_row > 40:
 905                        refresh_row = 0
 906
 907            self._ui_rows[refresh_row].update(
 908                refresh_row,
 909                party_vals_displayed[refresh_row],
 910                sub_scroll_width=sub_scroll_width,
 911                sub_scroll_height=sub_scroll_height,
 912                lineheight=lineheight,
 913                columnwidget=columnwidget,
 914                join_text=self._join_text,
 915                existing_selection=self._selection,
 916                filter_text=self._filter_text,
 917                tab=self)
 918            self._refresh_ui_row = refresh_row + 1
 919            rowcount -= 1
 920
 921        # So our selection callbacks can start firing..
 922        def refresh_off() -> None:
 923            self._refreshing_list = False
 924
 925        ba.pushcall(refresh_off)
 926
 927    def _process_pending_party_infos(self) -> None:
 928        starttime = time.time()
 929
 930        # We want to do this in small enough pieces to not cause UI hitches.
 931        chunksize = 30
 932        parties_in = self._pending_party_infos[:chunksize]
 933        self._pending_party_infos = self._pending_party_infos[chunksize:]
 934        for party_in in parties_in:
 935            addr = party_in['a']
 936            assert isinstance(addr, str)
 937            port = party_in['p']
 938            assert isinstance(port, int)
 939            party_key = f'{addr}_{port}'
 940            party = self._parties.get(party_key)
 941            if party is None:
 942                # If this party is new to us, init it.
 943                party = PartyEntry(address=addr,
 944                                   next_ping_time=ba.time(ba.TimeType.REAL) +
 945                                   0.001 * party_in['pd'],
 946                                   index=self._next_entry_index)
 947                self._parties[party_key] = party
 948                self._parties_sorted.append((party_key, party))
 949                self._party_lists_dirty = True
 950                self._next_entry_index += 1
 951                assert isinstance(party.address, str)
 952                assert isinstance(party.next_ping_time, float)
 953
 954            # Now, new or not, update its values.
 955            party.queue = party_in.get('q')
 956            assert isinstance(party.queue, (str, type(None)))
 957            party.port = port
 958            party.name = party_in['n']
 959            assert isinstance(party.name, str)
 960            party.size = party_in['s']
 961            assert isinstance(party.size, int)
 962            party.size_max = party_in['sm']
 963            assert isinstance(party.size_max, int)
 964
 965            # Server provides this in milliseconds; we use seconds.
 966            party.ping_interval = 0.001 * party_in['pi']
 967            assert isinstance(party.ping_interval, float)
 968            party.stats_addr = party_in['sa']
 969            assert isinstance(party.stats_addr, (str, type(None)))
 970
 971            # Make sure the party's UI gets updated.
 972            party.clean_display_index = None
 973
 974        if DEBUG_PROCESSING and parties_in:
 975            print(f'Processed {len(parties_in)} raw party infos in'
 976                  f' {time.time()-starttime:.5f}s.')
 977
 978    def _update_party_lists(self) -> None:
 979        if not self._party_lists_dirty:
 980            return
 981        starttime = time.time()
 982        assert len(self._parties_sorted) == len(self._parties)
 983
 984        self._parties_sorted.sort(key=lambda p: (
 985            p[1].queue is None,  # Show non-queued last.
 986            p[1].ping if p[1].ping is not None else 999999.0,
 987            p[1].index))
 988
 989        # If signed out or errored, show no parties.
 990        if (_ba.get_v1_account_state() != 'signed_in'
 991                or not self._have_valid_server_list):
 992            self._parties_displayed = {}
 993        else:
 994            if self._filter_value:
 995                filterval = self._filter_value.lower()
 996                self._parties_displayed = {
 997                    k: v
 998                    for k, v in self._parties_sorted
 999                    if filterval in v.name.lower()
1000                }
1001            else:
1002                self._parties_displayed = dict(self._parties_sorted)
1003
1004        # Any time our selection disappears from the displayed list, go back to
1005        # auto-selecting the top entry.
1006        if (self._selection is not None
1007                and self._selection.entry_key not in self._parties_displayed):
1008            self._have_user_selected_row = False
1009
1010        # Whenever the user hasn't selected something, keep the first visible
1011        # row selected.
1012        if not self._have_user_selected_row and self._parties_displayed:
1013            firstpartykey = next(iter(self._parties_displayed))
1014            self._selection = Selection(firstpartykey, SelectionComponent.NAME)
1015
1016        self._party_lists_dirty = False
1017        if DEBUG_PROCESSING:
1018            print(f'Sorted {len(self._parties_sorted)} parties in'
1019                  f' {time.time()-starttime:.5f}s.')
1020
1021    def _query_party_list_periodically(self) -> None:
1022        now = ba.time(ba.TimeType.REAL)
1023
1024        # Fire off a new public-party query periodically.
1025        if (self._last_server_list_query_time is None
1026                or now - self._last_server_list_query_time > 0.001 *
1027                _ba.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000)):
1028            self._last_server_list_query_time = now
1029            if DEBUG_SERVER_COMMUNICATION:
1030                print('REQUESTING SERVER LIST')
1031            if _ba.get_v1_account_state() == 'signed_in':
1032                _ba.add_transaction(
1033                    {
1034                        'type': 'PUBLIC_PARTY_QUERY',
1035                        'proto': ba.app.protocol_version,
1036                        'lang': ba.app.lang.language
1037                    },
1038                    callback=ba.WeakCall(self._on_public_party_query_result))
1039                _ba.run_transactions()
1040            else:
1041                self._on_public_party_query_result(None)
1042
1043    def _ping_parties_periodically(self) -> None:
1044        now = ba.time(ba.TimeType.REAL)
1045
1046        # Go through our existing public party entries firing off pings
1047        # for any that have timed out.
1048        for party in list(self._parties.values()):
1049            if party.next_ping_time <= now and ba.app.ping_thread_count < 15:
1050
1051                # Crank the interval up for high-latency or non-responding
1052                # parties to save us some useless work.
1053                mult = 1
1054                if party.ping_responses == 0:
1055                    if party.ping_attempts > 4:
1056                        mult = 10
1057                    elif party.ping_attempts > 2:
1058                        mult = 5
1059                if party.ping is not None:
1060                    mult = (10 if party.ping > 300 else
1061                            5 if party.ping > 150 else 2)
1062
1063                interval = party.ping_interval * mult
1064                if DEBUG_SERVER_COMMUNICATION:
1065                    print(f'pinging #{party.index} cur={party.ping} '
1066                          f'interval={interval} '
1067                          f'({party.ping_responses}/{party.ping_attempts})')
1068
1069                party.next_ping_time = now + party.ping_interval * mult
1070                party.ping_attempts += 1
1071
1072                PingThread(party.address, party.port,
1073                           ba.WeakCall(self._ping_callback)).start()
1074
1075    def _ping_callback(self, address: str, port: int | None,
1076                       result: float | None) -> None:
1077        # Look for a widget corresponding to this target.
1078        # If we find one, update our list.
1079        party_key = f'{address}_{port}'
1080        party = self._parties.get(party_key)
1081        if party is not None:
1082            if result is not None:
1083                party.ping_responses += 1
1084
1085            # We now smooth ping a bit to reduce jumping around in the list
1086            # (only where pings are relatively good).
1087            current_ping = party.ping
1088            if (current_ping is not None and result is not None
1089                    and result < 150):
1090                smoothing = 0.7
1091                party.ping = (smoothing * current_ping +
1092                              (1.0 - smoothing) * result)
1093            else:
1094                party.ping = result
1095
1096            # Need to re-sort the list and update the row display.
1097            party.clean_display_index = None
1098            self._party_lists_dirty = True
1099
1100    def _fetch_local_addr_cb(self, val: str) -> None:
1101        self._local_address = str(val)
1102
1103    def _on_public_party_accessible_response(
1104            self, data: dict[str, Any] | None) -> None:
1105
1106        # If we've got status text widgets, update them.
1107        text = self._host_status_text
1108        if text:
1109            if data is None:
1110                ba.textwidget(
1111                    edit=text,
1112                    text=ba.Lstr(resource='gatherWindow.'
1113                                 'partyStatusNoConnectionText'),
1114                    color=(1, 0, 0),
1115                )
1116            else:
1117                if not data.get('accessible', False):
1118                    ex_line: str | ba.Lstr
1119                    if self._local_address is not None:
1120                        ex_line = ba.Lstr(
1121                            value='\n${A} ${B}',
1122                            subs=[('${A}',
1123                                   ba.Lstr(resource='gatherWindow.'
1124                                           'manualYourLocalAddressText')),
1125                                  ('${B}', self._local_address)])
1126                    else:
1127                        ex_line = ''
1128                    ba.textwidget(
1129                        edit=text,
1130                        text=ba.Lstr(
1131                            value='${A}\n${B}${C}',
1132                            subs=[('${A}',
1133                                   ba.Lstr(resource='gatherWindow.'
1134                                           'partyStatusNotJoinableText')),
1135                                  ('${B}',
1136                                   ba.Lstr(resource='gatherWindow.'
1137                                           'manualRouterForwardingText',
1138                                           subs=[('${PORT}',
1139                                                  str(_ba.get_game_port()))])),
1140                                  ('${C}', ex_line)]),
1141                        color=(1, 0, 0))
1142                else:
1143                    ba.textwidget(edit=text,
1144                                  text=ba.Lstr(resource='gatherWindow.'
1145                                               'partyStatusJoinableText'),
1146                                  color=(0, 1, 0))
1147
1148    def _do_status_check(self) -> None:
1149        from ba.internal import master_server_get
1150        ba.textwidget(edit=self._host_status_text,
1151                      color=(1, 1, 0),
1152                      text=ba.Lstr(resource='gatherWindow.'
1153                                   'partyStatusCheckingText'))
1154        master_server_get('bsAccessCheck', {'b': ba.app.build_number},
1155                          callback=ba.WeakCall(
1156                              self._on_public_party_accessible_response))
1157
1158    def _on_start_advertizing_press(self) -> None:
1159        from bastd.ui.account import show_sign_in_prompt
1160        if _ba.get_v1_account_state() != 'signed_in':
1161            show_sign_in_prompt()
1162            return
1163
1164        name = cast(str, ba.textwidget(query=self._host_name_text))
1165        if name == '':
1166            ba.screenmessage(ba.Lstr(resource='internal.invalidNameErrorText'),
1167                             color=(1, 0, 0))
1168            ba.playsound(ba.getsound('error'))
1169            return
1170        _ba.set_public_party_name(name)
1171        cfg = ba.app.config
1172        cfg['Public Party Name'] = name
1173        cfg.commit()
1174        ba.playsound(ba.getsound('shieldUp'))
1175        _ba.set_public_party_enabled(True)
1176
1177        # In GUI builds we want to authenticate clients only when hosting
1178        # public parties.
1179        _ba.set_authenticate_clients(True)
1180
1181        self._do_status_check()
1182        ba.buttonwidget(
1183            edit=self._host_toggle_button,
1184            label=ba.Lstr(
1185                resource='gatherWindow.makePartyPrivateText',
1186                fallback_resource='gatherWindow.stopAdvertisingText'),
1187            on_activate_call=self._on_stop_advertising_press)
1188
1189    def _on_stop_advertising_press(self) -> None:
1190        _ba.set_public_party_enabled(False)
1191
1192        # In GUI builds we want to authenticate clients only when hosting
1193        # public parties.
1194        _ba.set_authenticate_clients(False)
1195        ba.playsound(ba.getsound('shieldDown'))
1196        text = self._host_status_text
1197        if text:
1198            ba.textwidget(
1199                edit=text,
1200                text=ba.Lstr(resource='gatherWindow.'
1201                             'partyStatusNotPublicText'),
1202                color=(0.6, 0.6, 0.6),
1203            )
1204        ba.buttonwidget(
1205            edit=self._host_toggle_button,
1206            label=ba.Lstr(
1207                resource='gatherWindow.makePartyPublicText',
1208                fallback_resource='gatherWindow.startAdvertisingText'),
1209            on_activate_call=self._on_start_advertizing_press)
1210
1211    def on_public_party_activate(self, party: PartyEntry) -> None:
1212        """Called when a party is clicked or otherwise activated."""
1213        self.save_state()
1214        if party.queue is not None:
1215            from bastd.ui.partyqueue import PartyQueueWindow
1216            ba.playsound(ba.getsound('swish'))
1217            PartyQueueWindow(party.queue, party.address, party.port)
1218        else:
1219            address = party.address
1220            port = party.port
1221
1222            # Rate limit this a bit.
1223            now = time.time()
1224            last_connect_time = self._last_connect_attempt_time
1225            if last_connect_time is None or now - last_connect_time > 2.0:
1226                _ba.connect_to_party(address, port=port)
1227                self._last_connect_attempt_time = now
1228
1229    def set_public_party_selection(self, sel: Selection) -> None:
1230        """Set the sel."""
1231        if self._refreshing_list:
1232            return
1233        self._selection = sel
1234        self._have_user_selected_row = True
1235
1236    def _on_max_public_party_size_minus_press(self) -> None:
1237        val = max(1, _ba.get_public_party_max_size() - 1)
1238        _ba.set_public_party_max_size(val)
1239        ba.textwidget(edit=self._host_max_party_size_value, text=str(val))
1240
1241    def _on_max_public_party_size_plus_press(self) -> None:
1242        val = _ba.get_public_party_max_size()
1243        val += 1
1244        _ba.set_public_party_max_size(val)
1245        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)
293    def __init__(self, window: GatherWindow) -> None:
294        super().__init__(window)
295        self._container: ba.Widget | None = None
296        self._join_text: ba.Widget | None = None
297        self._host_text: ba.Widget | None = None
298        self._filter_text: ba.Widget | None = None
299        self._local_address: str | None = None
300        self._last_connect_attempt_time: float | None = None
301        self._sub_tab: SubTabType = SubTabType.JOIN
302        self._selection: Selection | None = None
303        self._refreshing_list = False
304        self._update_timer: ba.Timer | None = None
305        self._host_scrollwidget: ba.Widget | None = None
306        self._host_name_text: ba.Widget | None = None
307        self._host_toggle_button: ba.Widget | None = None
308        self._last_server_list_query_time: float | None = None
309        self._join_list_column: ba.Widget | None = None
310        self._join_status_text: ba.Widget | None = None
311        self._host_max_party_size_value: ba.Widget | None = None
312        self._host_max_party_size_minus_button: (ba.Widget | None) = None
313        self._host_max_party_size_plus_button: (ba.Widget | None) = None
314        self._host_status_text: ba.Widget | None = None
315        self._signed_in = False
316        self._ui_rows: list[UIRow] = []
317        self._refresh_ui_row = 0
318        self._have_user_selected_row = False
319        self._first_valid_server_list_time: float | None = None
320
321        # Parties indexed by id:
322        self._parties: dict[str, PartyEntry] = {}
323
324        # Parties sorted in display order:
325        self._parties_sorted: list[tuple[str, PartyEntry]] = []
326        self._party_lists_dirty = True
327
328        # Sorted parties with filter applied:
329        self._parties_displayed: dict[str, PartyEntry] = {}
330
331        self._next_entry_index = 0
332        self._have_server_list_response = False
333        self._have_valid_server_list = False
334        self._filter_value = ''
335        self._pending_party_infos: list[dict[str, Any]] = []
336        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:
338    def on_activate(
339        self,
340        parent_widget: ba.Widget,
341        tab_button: ba.Widget,
342        region_width: float,
343        region_height: float,
344        region_left: float,
345        region_bottom: float,
346    ) -> ba.Widget:
347        c_width = region_width
348        c_height = region_height - 20
349        self._container = ba.containerwidget(
350            parent=parent_widget,
351            position=(region_left,
352                      region_bottom + (region_height - c_height) * 0.5),
353            size=(c_width, c_height),
354            background=False,
355            selection_loops_to_parent=True)
356        v = c_height - 30
357        self._join_text = ba.textwidget(
358            parent=self._container,
359            position=(c_width * 0.5 - 245, v - 13),
360            color=(0.6, 1.0, 0.6),
361            scale=1.3,
362            size=(200, 30),
363            maxwidth=250,
364            h_align='left',
365            v_align='center',
366            click_activate=True,
367            selectable=True,
368            autoselect=True,
369            on_activate_call=lambda: self._set_sub_tab(
370                SubTabType.JOIN,
371                region_width,
372                region_height,
373                playsound=True,
374            ),
375            text=ba.Lstr(resource='gatherWindow.'
376                         'joinPublicPartyDescriptionText'))
377        self._host_text = ba.textwidget(
378            parent=self._container,
379            position=(c_width * 0.5 + 45, v - 13),
380            color=(0.6, 1.0, 0.6),
381            scale=1.3,
382            size=(200, 30),
383            maxwidth=250,
384            h_align='left',
385            v_align='center',
386            click_activate=True,
387            selectable=True,
388            autoselect=True,
389            on_activate_call=lambda: self._set_sub_tab(
390                SubTabType.HOST,
391                region_width,
392                region_height,
393                playsound=True,
394            ),
395            text=ba.Lstr(resource='gatherWindow.'
396                         'hostPublicPartyDescriptionText'))
397        ba.widget(edit=self._join_text, up_widget=tab_button)
398        ba.widget(edit=self._host_text,
399                  left_widget=self._join_text,
400                  up_widget=tab_button)
401        ba.widget(edit=self._join_text, right_widget=self._host_text)
402
403        # Attempt to fetch our local address so we have it for error messages.
404        if self._local_address is None:
405            AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start()
406
407        self._set_sub_tab(self._sub_tab, region_width, region_height)
408        self._update_timer = ba.Timer(0.1,
409                                      ba.WeakCall(self._update),
410                                      repeat=True,
411                                      timetype=ba.TimeType.REAL)
412        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:
414    def on_deactivate(self) -> None:
415        self._update_timer = None

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

def save_state(self) -> None:
417    def save_state(self) -> None:
418
419        # Save off a small number of parties with the lowest ping; we'll
420        # display these immediately when our UI comes back up which should
421        # be enough to make things feel nice and crisp while we do a full
422        # server re-query or whatnot.
423        ba.app.ui.window_states[type(self)] = State(
424            sub_tab=self._sub_tab,
425            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
426            next_entry_index=self._next_entry_index,
427            filter_value=self._filter_value,
428            have_server_list_response=self._have_server_list_response,
429            have_valid_server_list=self._have_valid_server_list)

Called when the parent window is saving state.

def restore_state(self) -> None:
431    def restore_state(self) -> None:
432        state = ba.app.ui.window_states.get(type(self))
433        if state is None:
434            state = State()
435        assert isinstance(state, State)
436        self._sub_tab = state.sub_tab
437
438        # Restore the parties we stored.
439        if state.parties:
440            self._parties = {
441                key: copy.copy(party)
442                for key, party in state.parties
443            }
444            self._parties_sorted = list(self._parties.items())
445            self._party_lists_dirty = True
446
447            self._next_entry_index = state.next_entry_index
448
449            # FIXME: should save/restore these too?..
450            self._have_server_list_response = state.have_server_list_response
451            self._have_valid_server_list = state.have_valid_server_list
452        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:
1211    def on_public_party_activate(self, party: PartyEntry) -> None:
1212        """Called when a party is clicked or otherwise activated."""
1213        self.save_state()
1214        if party.queue is not None:
1215            from bastd.ui.partyqueue import PartyQueueWindow
1216            ba.playsound(ba.getsound('swish'))
1217            PartyQueueWindow(party.queue, party.address, party.port)
1218        else:
1219            address = party.address
1220            port = party.port
1221
1222            # Rate limit this a bit.
1223            now = time.time()
1224            last_connect_time = self._last_connect_attempt_time
1225            if last_connect_time is None or now - last_connect_time > 2.0:
1226                _ba.connect_to_party(address, port=port)
1227                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:
1229    def set_public_party_selection(self, sel: Selection) -> None:
1230        """Set the sel."""
1231        if self._refreshing_list:
1232            return
1233        self._selection = sel
1234        self._have_user_selected_row = True

Set the sel.

Inherited Members
bastd.ui.gather.GatherTab
window