Source code for bauiv1lib.gather.publictab

# Released under the MIT License. See LICENSE for details.
#
# pylint: disable=too-many-lines
"""Defines the public tab in the gather UI."""

from __future__ import annotations

import copy
import time
import logging
from threading import Thread
from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, override

from bauiv1lib.gather import GatherTab
import bauiv1 as bui
import bascenev1 as bs

if TYPE_CHECKING:
    from typing import Callable, Any

    from bauiv1lib.gather import GatherWindow

# Print a bit of info about pings, queries, etc.
DEBUG_SERVER_COMMUNICATION = False
DEBUG_PROCESSING = False


[docs] class SubTabType(Enum): """Available sub-tabs.""" JOIN = 'join' HOST = 'host'
[docs] @dataclass class PartyEntry: """Info about a public party.""" 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
[docs] def get_key(self) -> str: """Return the key used to store this party.""" return f'{self.address}_{self.port}'
[docs] class UIRow: """Wrangles UI for a row in the party list.""" def __init__(self) -> None: self._name_widget: bui.Widget | None = None self._size_widget: bui.Widget | None = None self._ping_widget: bui.Widget | None = None self._stats_button: bui.Widget | None = None def __del__(self) -> None: self._clear() def _clear(self) -> None: for widget in [ self._name_widget, self._size_widget, self._ping_widget, self._stats_button, ]: if widget: widget.delete()
[docs] def update( self, index: int, party: PartyEntry, sub_scroll_width: float, sub_scroll_height: float, lineheight: float, columnwidget: bui.Widget, join_text: bui.Widget, filter_text: bui.Widget, existing_selection: Selection | None, tab: PublicGatherTab, ) -> None: """Update for the given data.""" # pylint: disable=too-many-locals # pylint: disable=too-many-positional-arguments plus = bui.app.plus assert plus is not None # Quick-out: if we've been marked clean for a certain index and # we're still at that index, we're done. if party.clean_display_index == index: return ping_good = plus.get_v1_account_misc_read_val('pingGood', 100) ping_med = plus.get_v1_account_misc_read_val('pingMed', 500) self._clear() hpos = 20 vpos = sub_scroll_height - lineheight * index - 50 self._name_widget = bui.textwidget( text=bui.Lstr(value=party.name), parent=columnwidget, size=(sub_scroll_width * 0.46, 20), position=(0 + hpos, 4 + vpos), selectable=True, on_select_call=bui.WeakCall( tab.set_public_party_selection, Selection(party.get_key(), SelectionComponent.NAME), ), on_activate_call=bui.WeakCall(tab.on_public_party_activate, party), click_activate=True, maxwidth=sub_scroll_width * 0.45, corner_scale=1.4, autoselect=True, color=(1, 1, 1, 0.3 if party.ping is None else 1.0), h_align='left', v_align='center', ) bui.widget( edit=self._name_widget, left_widget=join_text, show_buffer_top=64.0, show_buffer_bottom=64.0, ) if existing_selection == Selection( party.get_key(), SelectionComponent.NAME ): bui.containerwidget( edit=columnwidget, selected_child=self._name_widget ) if party.stats_addr: url = party.stats_addr.replace( '${ACCOUNT}', plus.get_v1_account_misc_read_val_2( 'resolvedAccountID', 'UNKNOWN' ), ) self._stats_button = bui.buttonwidget( color=(0.3, 0.6, 0.94), textcolor=(1.0, 1.0, 1.0), label=bui.Lstr(resource='statsText'), parent=columnwidget, autoselect=True, on_activate_call=bui.Call(bui.open_url, url), on_select_call=bui.WeakCall( tab.set_public_party_selection, Selection(party.get_key(), SelectionComponent.STATS_BUTTON), ), size=(120, 40), position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), scale=0.9, ) if existing_selection == Selection( party.get_key(), SelectionComponent.STATS_BUTTON ): bui.containerwidget( edit=columnwidget, selected_child=self._stats_button ) self._size_widget = bui.textwidget( text=str(party.size) + '/' + str(party.size_max), parent=columnwidget, size=(0, 0), position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), scale=0.7, color=(0.8, 0.8, 0.8), h_align='right', v_align='center', ) if index == 0: bui.widget(edit=self._name_widget, up_widget=filter_text) if self._stats_button: bui.widget(edit=self._stats_button, up_widget=filter_text) self._ping_widget = bui.textwidget( parent=columnwidget, size=(0, 0), position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), scale=0.7, h_align='right', v_align='center', ) if party.ping is None: bui.textwidget( edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5) ) else: bui.textwidget( edit=self._ping_widget, text=str(int(party.ping)), color=( (0, 1, 0) if party.ping <= ping_good else (1, 1, 0) if party.ping <= ping_med else (1, 0, 0) ), ) party.clean_display_index = index
[docs] @dataclass class State: """State saved/restored only while the app is running.""" sub_tab: SubTabType = SubTabType.JOIN parties: list[tuple[str, PartyEntry]] | None = None next_entry_index: int = 0 filter_value: str = '' have_server_list_response: bool = False have_valid_server_list: bool = False
[docs] class SelectionComponent(Enum): """Describes what part of an entry is selected.""" NAME = 'name' STATS_BUTTON = 'stats_button'
[docs] @dataclass class Selection: """Describes the currently selected list element.""" entry_key: str component: SelectionComponent
[docs] class AddrFetchThread(Thread): """Thread for fetching an address in the bg.""" def __init__(self, call: Callable[[Any], Any]): super().__init__() self._call = call
[docs] @override def run(self) -> None: sock: socket.socket | None = None try: # FIXME: Update this to work with IPv6 at some point. import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(('8.8.8.8', 80)) val = sock.getsockname()[0] bui.pushcall(bui.Call(self._call, val), from_other_thread=True) except Exception as exc: from efro.error import is_udp_communication_error # Ignore expected network errors; log others. if is_udp_communication_error(exc): pass else: logging.exception('Error in addr-fetch-thread') finally: if sock is not None: sock.close()
[docs] class PingThread(Thread): """Thread for sending out game pings.""" def __init__( self, address: str, port: int, call: Callable[[str, int, float | None], int | None], ): super().__init__() self._address = address self._port = port self._call = call
[docs] @override def run(self) -> None: assert bui.app.classic is not None bui.app.classic.ping_thread_count += 1 sock: socket.socket | None = None try: import socket socket_type = bui.get_ip_address_type(self._address) sock = socket.socket(socket_type, socket.SOCK_DGRAM) sock.connect((self._address, self._port)) accessible = False starttime = time.time() # Send a few pings and wait a second for # a response. sock.settimeout(1) for _i in range(3): sock.send(b'\x0b') result: bytes | None try: # 11: BA_PACKET_SIMPLE_PING result = sock.recv(10) except Exception: result = None if result == b'\x0c': # 12: BA_PACKET_SIMPLE_PONG accessible = True break time.sleep(1) ping = (time.time() - starttime) * 1000.0 bui.pushcall( bui.Call( self._call, self._address, self._port, ping if accessible else None, ), from_other_thread=True, ) except Exception as exc: from efro.error import is_udp_communication_error if is_udp_communication_error(exc): pass else: if bui.do_once(): logging.exception('Error on gather ping.') finally: try: if sock is not None: sock.close() except Exception: if bui.do_once(): logging.exception('Error on gather ping cleanup') bui.app.classic.ping_thread_count -= 1
[docs] class PublicGatherTab(GatherTab): """The public tab in the gather UI""" def __init__(self, window: GatherWindow) -> None: super().__init__(window) self._container: bui.Widget | None = None self._join_text: bui.Widget | None = None self._host_text: bui.Widget | None = None self._filter_text: bui.Widget | None = None self._local_address: str | None = None self._last_connect_attempt_time: float | None = None self._sub_tab: SubTabType = SubTabType.JOIN self._selection: Selection | None = None self._refreshing_list = False self._update_timer: bui.AppTimer | None = None self._host_scrollwidget: bui.Widget | None = None self._host_name_text: bui.Widget | None = None self._host_toggle_button: bui.Widget | None = None self._last_server_list_query_time: float | None = None self._join_list_column: bui.Widget | None = None self._join_status_text: bui.Widget | None = None self._no_servers_found_text: bui.Widget | None = None self._host_max_party_size_value: bui.Widget | None = None self._host_max_party_size_minus_button: bui.Widget | None = None self._host_max_party_size_plus_button: bui.Widget | None = None self._host_status_text: bui.Widget | None = None self._signed_in = False self._ui_rows: list[UIRow] = [] self._refresh_ui_row = 0 self._have_user_selected_row = False self._first_valid_server_list_time: float | None = None # Parties indexed by id: self._parties: dict[str, PartyEntry] = {} # Parties sorted in display order: self._parties_sorted: list[tuple[str, PartyEntry]] = [] self._party_lists_dirty = True # Sorted parties with filter applied: self._parties_displayed: dict[str, PartyEntry] = {} self._next_entry_index = 0 self._have_server_list_response = False self._have_valid_server_list = False self._filter_value = '' self._pending_party_infos: list[dict[str, Any]] = [] self._last_sub_scroll_height = 0.0
[docs] @override def on_activate( self, parent_widget: bui.Widget, tab_button: bui.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float, ) -> bui.Widget: # pylint: disable=too-many-positional-arguments c_width = region_width c_height = region_height - 20 self._container = bui.containerwidget( parent=parent_widget, position=( region_left, region_bottom + (region_height - c_height) * 0.5, ), size=(c_width, c_height), background=False, selection_loops_to_parent=True, ) v = c_height - 30 self._join_text = bui.textwidget( parent=self._container, position=(c_width * 0.5 - 245, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, size=(200, 30), maxwidth=250, h_align='left', v_align='center', click_activate=True, selectable=True, autoselect=True, on_activate_call=lambda: self._set_sub_tab( SubTabType.JOIN, region_width, region_height, playsound=True, ), text=bui.Lstr( resource='gatherWindow.' 'joinPublicPartyDescriptionText' ), glow_type='uniform', ) self._host_text = bui.textwidget( parent=self._container, position=(c_width * 0.5 + 45, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, size=(200, 30), maxwidth=250, h_align='left', v_align='center', click_activate=True, selectable=True, autoselect=True, on_activate_call=lambda: self._set_sub_tab( SubTabType.HOST, region_width, region_height, playsound=True, ), text=bui.Lstr( resource='gatherWindow.' 'hostPublicPartyDescriptionText' ), glow_type='uniform', ) bui.widget(edit=self._join_text, up_widget=tab_button) bui.widget( edit=self._host_text, left_widget=self._join_text, up_widget=tab_button, ) bui.widget(edit=self._join_text, right_widget=self._host_text) # Attempt to fetch our local address so we have it for error messages. if self._local_address is None: AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() self._set_sub_tab(self._sub_tab, region_width, region_height) self._update_timer = bui.AppTimer( 0.1, bui.WeakCall(self._update), repeat=True ) return self._container
[docs] @override def on_deactivate(self) -> None: self._update_timer = None
[docs] @override def save_state(self) -> None: # Save off a small number of parties with the lowest ping; we'll # display these immediately when our UI comes back up which should # be enough to make things feel nice and crisp while we do a full # server re-query or whatnot. assert bui.app.classic is not None bui.app.ui_v1.window_states[type(self)] = State( sub_tab=self._sub_tab, parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]], next_entry_index=self._next_entry_index, filter_value=self._filter_value, have_server_list_response=self._have_server_list_response, have_valid_server_list=self._have_valid_server_list, )
[docs] @override def restore_state(self) -> None: assert bui.app.classic is not None state = bui.app.ui_v1.window_states.get(type(self)) if state is None: state = State() assert isinstance(state, State) self._sub_tab = state.sub_tab # Restore the parties we stored. if state.parties: self._parties = { key: copy.copy(party) for key, party in state.parties } self._parties_sorted = list(self._parties.items()) self._party_lists_dirty = True self._next_entry_index = state.next_entry_index # FIXME: should save/restore these too?.. self._have_server_list_response = state.have_server_list_response self._have_valid_server_list = state.have_valid_server_list self._filter_value = state.filter_value
def _set_sub_tab( self, value: SubTabType, region_width: float, region_height: float, playsound: bool = False, ) -> None: assert self._container if playsound: bui.getsound('click01').play() # Reset our selection. # (prevents selecting something way down the list if we switched away # and came back) self._selection = None self._have_user_selected_row = False # Reset refresh to the top and make sure everything refreshes. self._refresh_ui_row = 0 for party in self._parties.values(): party.clean_display_index = None self._sub_tab = value active_color = (0.6, 1.0, 0.6) inactive_color = (0.5, 0.4, 0.5) bui.textwidget( edit=self._join_text, color=active_color if value is SubTabType.JOIN else inactive_color, ) bui.textwidget( edit=self._host_text, color=active_color if value is SubTabType.HOST else inactive_color, ) # Clear anything existing in the old sub-tab. for widget in self._container.get_children(): if widget and widget not in {self._host_text, self._join_text}: widget.delete() if value is SubTabType.JOIN: self._build_join_tab(region_width, region_height) if value is SubTabType.HOST: self._build_host_tab(region_width, region_height) def _build_join_tab( self, region_width: float, region_height: float ) -> None: c_width = region_width c_height = region_height - 20 sub_scroll_height = c_height - 125 sub_scroll_width = 830 v = c_height - 35 v -= 60 filter_txt = bui.Lstr(resource='filterText') self._filter_text = bui.textwidget( parent=self._container, text=self._filter_value, size=(350, 45), position=(c_width * 0.5 - 150, v - 10), h_align='left', v_align='center', editable=True, maxwidth=310, description=filter_txt, ) bui.widget(edit=self._filter_text, up_widget=self._join_text) bui.textwidget( text=filter_txt, parent=self._container, size=(0, 0), position=(c_width * 0.5 - 170, v + 13), maxwidth=150, scale=0.8, color=(0.5, 0.46, 0.5), flatness=1.0, h_align='right', v_align='center', ) bui.textwidget( text=bui.Lstr(resource='nameText'), parent=self._container, size=(0, 0), position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), flatness=1.0, h_align='center', v_align='center', ) bui.textwidget( text=bui.Lstr(resource='gatherWindow.partySizeText'), parent=self._container, size=(0, 0), position=( c_width * 0.5 + sub_scroll_width * 0.5 - 110, v - 8, ), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), flatness=1.0, h_align='center', v_align='center', ) bui.textwidget( text=bui.Lstr(resource='gatherWindow.pingText'), parent=self._container, size=(0, 0), position=( c_width * 0.5 + sub_scroll_width * 0.5 - 30, v - 8, ), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), flatness=1.0, h_align='center', v_align='center', ) v -= sub_scroll_height + 23 self._host_scrollwidget = scrollw = bui.scrollwidget( parent=self._container, simple_culling_v=10, position=((c_width - sub_scroll_width) * 0.5, v), size=(sub_scroll_width, sub_scroll_height), claims_up_down=False, claims_left_right=True, autoselect=True, ) self._join_list_column = bui.containerwidget( parent=scrollw, background=False, size=(400, 400), claims_left_right=True, ) self._join_status_text = bui.textwidget( parent=self._container, text='', size=(0, 0), scale=0.9, flatness=1.0, shadow=0.0, h_align='center', v_align='top', maxwidth=c_width, color=(0.6, 0.6, 0.6), position=(c_width * 0.5, c_height * 0.5), ) self._no_servers_found_text = bui.textwidget( parent=self._container, text='', size=(0, 0), scale=0.9, flatness=1.0, shadow=0.0, h_align='center', v_align='top', color=(0.6, 0.6, 0.6), position=(c_width * 0.5, c_height * 0.5), ) def _build_host_tab( self, region_width: float, region_height: float ) -> None: c_width = region_width c_height = region_height - 20 v = c_height - 35 v -= 25 is_public_enabled = bs.get_public_party_enabled() v -= 30 bui.textwidget( parent=self._container, size=(0, 0), h_align='center', v_align='center', maxwidth=c_width * 0.9, scale=0.7, flatness=1.0, color=(0.5, 0.46, 0.5), position=(region_width * 0.5, v + 10), text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), ) v -= 30 party_name_text = bui.Lstr( resource='gatherWindow.partyNameText', fallback_resource='editGameListWindow.nameText', ) assert bui.app.classic is not None bui.textwidget( parent=self._container, size=(0, 0), h_align='right', v_align='center', maxwidth=200, scale=0.8, color=bui.app.ui_v1.infotextcolor, position=(210, v - 9), text=party_name_text, ) self._host_name_text = bui.textwidget( parent=self._container, editable=True, size=(535, 40), position=(230, v - 30), text=bui.app.config.get('Public Party Name', ''), maxwidth=494, shadow=0.3, flatness=1.0, description=party_name_text, autoselect=True, v_align='center', corner_scale=1.0, ) v -= 60 bui.textwidget( parent=self._container, size=(0, 0), h_align='right', v_align='center', maxwidth=200, scale=0.8, color=bui.app.ui_v1.infotextcolor, position=(210, v - 9), text=bui.Lstr( resource='maxPartySizeText', fallback_resource='maxConnectionsText', ), ) self._host_max_party_size_value = bui.textwidget( parent=self._container, size=(0, 0), h_align='center', v_align='center', scale=1.2, color=(1, 1, 1), position=(240, v - 9), text=str(bs.get_public_party_max_size()), ) btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( parent=self._container, size=(40, 40), on_activate_call=bui.WeakCall( self._on_max_public_party_size_minus_press ), position=(280, v - 26), label='-', autoselect=True, ) btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( parent=self._container, size=(40, 40), on_activate_call=bui.WeakCall( self._on_max_public_party_size_plus_press ), position=(350, v - 26), label='+', autoselect=True, ) v -= 50 v -= 70 if is_public_enabled: label = bui.Lstr( resource='gatherWindow.makePartyPrivateText', fallback_resource='gatherWindow.stopAdvertisingText', ) else: label = bui.Lstr( resource='gatherWindow.makePartyPublicText', fallback_resource='gatherWindow.startAdvertisingText', ) self._host_toggle_button = bui.buttonwidget( parent=self._container, label=label, size=(400, 80), on_activate_call=( self._on_stop_advertising_press if is_public_enabled else self._on_start_advertizing_press ), position=(c_width * 0.5 - 200, v), autoselect=True, up_widget=btn2, ) bui.widget(edit=self._host_name_text, down_widget=btn2) bui.widget(edit=btn2, up_widget=self._host_name_text) bui.widget(edit=btn1, up_widget=self._host_name_text) assert self._join_text is not None bui.widget(edit=self._join_text, down_widget=self._host_name_text) v -= 10 self._host_status_text = bui.textwidget( parent=self._container, text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'), size=(0, 0), scale=0.7, flatness=1.0, h_align='center', v_align='top', maxwidth=c_width * 0.9, color=(0.6, 0.56, 0.6), position=(c_width * 0.5, v), ) v -= 90 bui.textwidget( parent=self._container, text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'), size=(0, 0), scale=0.7, flatness=1.0, h_align='center', v_align='center', maxwidth=c_width * 0.9, color=(0.5, 0.46, 0.5), position=(c_width * 0.5, v), ) # If public sharing is already on, # launch a status-check immediately. if bs.get_public_party_enabled(): self._do_status_check() def _on_public_party_query_result( self, result: dict[str, Any] | None ) -> None: starttime = time.time() self._have_server_list_response = True if result is None: self._have_valid_server_list = False return if not self._have_valid_server_list: self._first_valid_server_list_time = time.time() self._have_valid_server_list = True parties_in = result['l'] assert isinstance(parties_in, list) self._pending_party_infos += parties_in # To avoid causing a stutter here, we do most processing of # these entries incrementally in our _update() method. # The one thing we do here is prune parties not contained in # this result. for partyval in list(self._parties.values()): partyval.claimed = False for party_in in parties_in: addr = party_in['a'] assert isinstance(addr, str) port = party_in['p'] assert isinstance(port, int) party_key = f'{addr}_{port}' party = self._parties.get(party_key) if party is not None: party.claimed = True self._parties = { key: val for key, val in list(self._parties.items()) if val.claimed } self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] self._party_lists_dirty = True # self._update_server_list() if DEBUG_PROCESSING: print( f'Handled public party query results in ' f'{time.time()-starttime:.5f}s.' ) def _update(self) -> None: """Periodic updating.""" plus = bui.app.plus assert plus is not None if self._sub_tab is SubTabType.JOIN: # Keep our filter-text up to date from the UI. text = self._filter_text if text: filter_value = cast(str, bui.textwidget(query=text)) if filter_value != self._filter_value: self._filter_value = filter_value self._party_lists_dirty = True # Also wipe out party clean-row states. # (otherwise if a party disappears from a row due to # filtering and then reappears on that same row when # the filter is removed it may not update) for party in self._parties.values(): party.clean_display_index = None self._query_party_list_periodically() self._ping_parties_periodically() # If any new party infos have come in, apply some of them. self._process_pending_party_infos() # Anytime we sign in/out, make sure we refresh our list. signed_in = plus.get_v1_account_state() == 'signed_in' if self._signed_in != signed_in: self._signed_in = signed_in self._party_lists_dirty = True # Update sorting to account for ping updates, new parties, etc. self._update_party_lists() # If we've got a party-name text widget, keep its value plugged # into our public host name. text = self._host_name_text if text: name = cast(str, bui.textwidget(query=self._host_name_text)) bs.set_public_party_name(name) # Update status text. status_text = self._join_status_text if status_text: if not signed_in: bui.textwidget( edit=status_text, text=bui.Lstr(resource='notSignedInText') ) else: # If we have a valid list, show no status; just the list. # Otherwise show either 'loading...' or 'error' depending # on whether this is our first go-round. if self._have_valid_server_list: bui.textwidget(edit=status_text, text='') else: if self._have_server_list_response: bui.textwidget( edit=status_text, text=bui.Lstr(resource='errorText'), ) else: bui.textwidget( edit=status_text, text=bui.Lstr( value='${A}...', subs=[ ( '${A}', bui.Lstr(resource='store.loadingText'), ) ], ), ) self._update_party_rows() def _update_party_rows(self) -> None: plus = bui.app.plus assert plus is not None columnwidget = self._join_list_column if not columnwidget: return assert self._join_text assert self._filter_text # Janky - allow escaping when there's nothing in our list. assert self._host_scrollwidget bui.containerwidget( edit=self._host_scrollwidget, claims_up_down=(len(self._parties_displayed) > 0), ) bui.textwidget(edit=self._no_servers_found_text, text='') # Clip if we have more UI rows than parties to show. clipcount = len(self._ui_rows) - len(self._parties_displayed) if clipcount > 0: clipcount = max(clipcount, 50) self._ui_rows = self._ui_rows[:-clipcount] # If we have no parties to show, we're done. if not self._parties_displayed: text = self._join_status_text if ( plus.get_v1_account_state() == 'signed_in' and cast(str, bui.textwidget(query=text)) == '' ): bui.textwidget( edit=self._no_servers_found_text, text=bui.Lstr(resource='noServersFoundText'), ) return sub_scroll_width = 830 lineheight = 42 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 bui.containerwidget( edit=columnwidget, size=(sub_scroll_width, sub_scroll_height) ) # Any time our height changes, reset the refresh back to the top # so we don't see ugly empty spaces appearing during initial list # filling. if sub_scroll_height != self._last_sub_scroll_height: self._refresh_ui_row = 0 self._last_sub_scroll_height = sub_scroll_height # Also note that we need to redisplay everything since its pos # will have changed.. :( for party in self._parties.values(): party.clean_display_index = None # Ew; this rebuilding generates deferred selection callbacks # so we need to push deferred notices so we know to ignore them. def refresh_on() -> None: self._refreshing_list = True bui.pushcall(refresh_on) # Ok, now here's the deal: we want to avoid creating/updating this # entire list at one time because it will lead to hitches. So we # refresh individual rows quickly in a loop. rowcount = min(12, len(self._parties_displayed)) party_vals_displayed = list(self._parties_displayed.values()) while rowcount > 0: refresh_row = self._refresh_ui_row % len(self._parties_displayed) if refresh_row >= len(self._ui_rows): self._ui_rows.append(UIRow()) refresh_row = len(self._ui_rows) - 1 # For the first few seconds after getting our first server-list, # refresh only the top section of the list; this allows the lowest # ping servers to show up more quickly. if self._first_valid_server_list_time is not None: if time.time() - self._first_valid_server_list_time < 4.0: if refresh_row > 40: refresh_row = 0 self._ui_rows[refresh_row].update( refresh_row, party_vals_displayed[refresh_row], sub_scroll_width=sub_scroll_width, sub_scroll_height=sub_scroll_height, lineheight=lineheight, columnwidget=columnwidget, join_text=self._join_text, existing_selection=self._selection, filter_text=self._filter_text, tab=self, ) self._refresh_ui_row = refresh_row + 1 rowcount -= 1 # So our selection callbacks can start firing.. def refresh_off() -> None: self._refreshing_list = False bui.pushcall(refresh_off) def _process_pending_party_infos(self) -> None: starttime = time.time() # We want to do this in small enough pieces to not cause UI hitches. chunksize = 30 parties_in = self._pending_party_infos[:chunksize] self._pending_party_infos = self._pending_party_infos[chunksize:] for party_in in parties_in: addr = party_in['a'] assert isinstance(addr, str) port = party_in['p'] assert isinstance(port, int) party_key = f'{addr}_{port}' party = self._parties.get(party_key) if party is None: # If this party is new to us, init it. party = PartyEntry( address=addr, next_ping_time=bui.apptime() + 0.001 * party_in['pd'], index=self._next_entry_index, ) self._parties[party_key] = party self._parties_sorted.append((party_key, party)) self._party_lists_dirty = True self._next_entry_index += 1 assert isinstance(party.address, str) assert isinstance(party.next_ping_time, float) # Now, new or not, update its values. party.queue = party_in.get('q') assert isinstance(party.queue, (str, type(None))) party.port = port party.name = party_in['n'] assert isinstance(party.name, str) party.size = party_in['s'] assert isinstance(party.size, int) party.size_max = party_in['sm'] assert isinstance(party.size_max, int) # Server provides this in milliseconds; we use seconds. party.ping_interval = 0.001 * party_in['pi'] assert isinstance(party.ping_interval, float) party.stats_addr = party_in['sa'] assert isinstance(party.stats_addr, (str, type(None))) # Make sure the party's UI gets updated. party.clean_display_index = None if DEBUG_PROCESSING and parties_in: print( f'Processed {len(parties_in)} raw party infos in' f' {time.time()-starttime:.5f}s.' ) def _update_party_lists(self) -> None: plus = bui.app.plus assert plus is not None if not self._party_lists_dirty: return starttime = time.time() assert len(self._parties_sorted) == len(self._parties) self._parties_sorted.sort( key=lambda p: ( p[1].ping if p[1].ping is not None else 999999.0, p[1].index, ) ) # If signed out or errored, show no parties. if ( plus.get_v1_account_state() != 'signed_in' or not self._have_valid_server_list ): self._parties_displayed = {} else: if self._filter_value: filterval = self._filter_value.lower() self._parties_displayed = { k: v for k, v in self._parties_sorted if filterval in v.name.lower() } else: self._parties_displayed = dict(self._parties_sorted) # Any time our selection disappears from the displayed list, go back to # auto-selecting the top entry. if ( self._selection is not None and self._selection.entry_key not in self._parties_displayed ): self._have_user_selected_row = False # Whenever the user hasn't selected something, keep the first visible # row selected. if not self._have_user_selected_row and self._parties_displayed: firstpartykey = next(iter(self._parties_displayed)) self._selection = Selection(firstpartykey, SelectionComponent.NAME) self._party_lists_dirty = False if DEBUG_PROCESSING: print( f'Sorted {len(self._parties_sorted)} parties in' f' {time.time()-starttime:.5f}s.' ) def _query_party_list_periodically(self) -> None: now = bui.apptime() plus = bui.app.plus assert plus is not None # Fire off a new public-party query periodically. if ( self._last_server_list_query_time is None or now - self._last_server_list_query_time > 0.001 * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000) ): self._last_server_list_query_time = now if DEBUG_SERVER_COMMUNICATION: print('REQUESTING SERVER LIST') if plus.get_v1_account_state() == 'signed_in': plus.add_v1_account_transaction( { 'type': 'PUBLIC_PARTY_QUERY', 'proto': bs.protocol_version(), 'lang': bui.app.lang.language, }, callback=bui.WeakCall(self._on_public_party_query_result), ) plus.run_v1_account_transactions() else: self._on_public_party_query_result(None) def _ping_parties_periodically(self) -> None: assert bui.app.classic is not None now = bui.apptime() # Go through our existing public party entries firing off pings # for any that have timed out. for party in list(self._parties.values()): if ( party.next_ping_time <= now and bui.app.classic.ping_thread_count < 15 ): # Crank the interval up for high-latency or non-responding # parties to save us some useless work. mult = 1 if party.ping_responses == 0: if party.ping_attempts > 4: mult = 10 elif party.ping_attempts > 2: mult = 5 if party.ping is not None: mult = ( 10 if party.ping > 300 else 5 if party.ping > 150 else 2 ) interval = party.ping_interval * mult if DEBUG_SERVER_COMMUNICATION: print( f'pinging #{party.index} cur={party.ping} ' f'interval={interval} ' f'({party.ping_responses}/{party.ping_attempts})' ) party.next_ping_time = now + party.ping_interval * mult party.ping_attempts += 1 PingThread( party.address, party.port, bui.WeakCall(self._ping_callback) ).start() def _ping_callback( self, address: str, port: int | None, result: float | None ) -> None: # Look for a widget corresponding to this target. # If we find one, update our list. party_key = f'{address}_{port}' party = self._parties.get(party_key) if party is not None: if result is not None: party.ping_responses += 1 # We now smooth ping a bit to reduce jumping around in the list # (only where pings are relatively good). current_ping = party.ping if current_ping is not None and result is not None and result < 150: smoothing = 0.7 party.ping = ( smoothing * current_ping + (1.0 - smoothing) * result ) else: party.ping = result # Need to re-sort the list and update the row display. party.clean_display_index = None self._party_lists_dirty = True def _fetch_local_addr_cb(self, val: str) -> None: self._local_address = str(val) def _on_public_party_accessible_response( self, data: dict[str, Any] | None ) -> None: # If we've got status text widgets, update them. text = self._host_status_text if text: if data is None: bui.textwidget( edit=text, text=bui.Lstr( resource='gatherWindow.' 'partyStatusNoConnectionText' ), color=(1, 0, 0), ) else: if not data.get('accessible', False): ex_line: str | bui.Lstr if self._local_address is not None: ex_line = bui.Lstr( value='\n${A} ${B}', subs=[ ( '${A}', bui.Lstr( resource='gatherWindow.' 'manualYourLocalAddressText' ), ), ('${B}', self._local_address), ], ) else: ex_line = '' bui.textwidget( edit=text, text=bui.Lstr( value='${A}\n${B}${C}', subs=[ ( '${A}', bui.Lstr( resource='gatherWindow.' 'partyStatusNotJoinableText' ), ), ( '${B}', bui.Lstr( resource='gatherWindow.' 'manualRouterForwardingText', subs=[ ( '${PORT}', str(bs.get_game_port()), ) ], ), ), ('${C}', ex_line), ], ), color=(1, 0, 0), ) else: bui.textwidget( edit=text, text=bui.Lstr( resource='gatherWindow.' 'partyStatusJoinableText' ), color=(0, 1, 0), ) def _do_status_check(self) -> None: assert bui.app.classic is not None bui.textwidget( edit=self._host_status_text, color=(1, 1, 0), text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'), ) bui.app.classic.master_server_v1_get( 'bsAccessCheck', {'b': bui.app.env.engine_build_number}, callback=bui.WeakCall(self._on_public_party_accessible_response), ) def _on_start_advertizing_press(self) -> None: from bauiv1lib.account import show_sign_in_prompt plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return name = cast(str, bui.textwidget(query=self._host_name_text)) if name == '': bui.screenmessage( bui.Lstr(resource='internal.invalidNameErrorText'), color=(1, 0, 0), ) bui.getsound('error').play() return bs.set_public_party_name(name) cfg = bui.app.config cfg['Public Party Name'] = name cfg.commit() bui.getsound('shieldUp').play() bs.set_public_party_enabled(True) # In GUI builds we want to authenticate clients only when hosting # public parties. bs.set_authenticate_clients(True) self._do_status_check() bui.buttonwidget( edit=self._host_toggle_button, label=bui.Lstr( resource='gatherWindow.makePartyPrivateText', fallback_resource='gatherWindow.stopAdvertisingText', ), on_activate_call=self._on_stop_advertising_press, ) def _on_stop_advertising_press(self) -> None: bs.set_public_party_enabled(False) # In GUI builds we want to authenticate clients only when hosting # public parties. bs.set_authenticate_clients(False) bui.getsound('shieldDown').play() text = self._host_status_text if text: bui.textwidget( edit=text, text=bui.Lstr( resource='gatherWindow.' 'partyStatusNotPublicText' ), color=(0.6, 0.6, 0.6), ) bui.buttonwidget( edit=self._host_toggle_button, label=bui.Lstr( resource='gatherWindow.makePartyPublicText', fallback_resource='gatherWindow.startAdvertisingText', ), on_activate_call=self._on_start_advertizing_press, )
[docs] def on_public_party_activate(self, party: PartyEntry) -> None: """Called when a party is clicked or otherwise activated.""" self.save_state() if party.queue is not None: from bauiv1lib.partyqueue import PartyQueueWindow bui.getsound('swish').play() PartyQueueWindow(party.queue, party.address, party.port) else: address = party.address port = party.port # Store UI location to return to when done. if bs.app.classic is not None: bs.app.classic.save_ui_state() # Rate limit this a bit. now = time.time() last_connect_time = self._last_connect_attempt_time if last_connect_time is None or now - last_connect_time > 2.0: bs.connect_to_party(address, port=port) self._last_connect_attempt_time = now
[docs] def set_public_party_selection(self, sel: Selection) -> None: """Set the sel.""" if self._refreshing_list: return self._selection = sel self._have_user_selected_row = True
def _on_max_public_party_size_minus_press(self) -> None: val = max(1, bs.get_public_party_max_size() - 1) bs.set_public_party_max_size(val) bui.textwidget(edit=self._host_max_party_size_value, text=str(val)) def _on_max_public_party_size_plus_press(self) -> None: val = bs.get_public_party_max_size() val += 1 bs.set_public_party_max_size(val) bui.textwidget(edit=self._host_max_party_size_value, text=str(val))