diff --git a/assets/src/ba_data/python/bastd/ui/gather/publictab.py b/assets/src/ba_data/python/bastd/ui/gather/publictab.py index 94e7bd19..383cb02a 100644 --- a/assets/src/ba_data/python/bastd/ui/gather/publictab.py +++ b/assets/src/ba_data/python/bastd/ui/gather/publictab.py @@ -1,9 +1,11 @@ # 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 threading from enum import Enum @@ -15,9 +17,11 @@ import ba from bastd.ui.gather.bases import GatherTab if TYPE_CHECKING: - from typing import Callable, Any, Optional, Dict, Union, Tuple + from typing import Callable, Any, Optional, Dict, Union, Tuple, List from bastd.ui.gather import GatherWindow +DEBUG_SERVER_COMMUNICATION = False + class SubTabType(Enum): """Available sub-tabs.""" @@ -25,18 +29,10 @@ class SubTabType(Enum): HOST = 'host' -@dataclass -class TabState: - """State saved/restored only while the app is running.""" - sub_tab: SubTabType = SubTabType.JOIN - - @dataclass class PartyEntry: - """Info about a party.""" + """Info about a public party.""" address: str - next_ping_time: float - ping: Optional[int] index: int queue: Optional[str] = None port: int = -1 @@ -44,7 +40,11 @@ class PartyEntry: size: int = -1 size_max: int = -1 claimed: bool = False + ping: Optional[int] = None ping_interval: float = -1.0 + next_ping_time: float = -1.0 + ping_attempts: int = 0 + ping_responses: int = 0 stats_addr: Optional[str] = None name_widget: Optional[ba.Widget] = None ping_widget: Optional[ba.Widget] = None @@ -52,6 +52,14 @@ class PartyEntry: size_widget: Optional[ba.Widget] = None +@dataclass +class State: + """State saved/restored only while the app is running.""" + sub_tab: SubTabType = SubTabType.JOIN + parties: Optional[List[PartyEntry]] = None + next_entry_index: int = 0 + + class SelectionComponent(Enum): """Describes what part of an entry is selected.""" NAME = 'name' @@ -187,17 +195,19 @@ class PublicGatherTab(GatherTab): self._host_scrollwidget: Optional[ba.Widget] = None self._host_name_text: Optional[ba.Widget] = None self._host_toggle_button: Optional[ba.Widget] = None - self._join_last_refresh_time = -99999.0 - self._host_columnwidget: Optional[ba.Widget] = None + self._last_server_list_query_time: Optional[float] = None + self._join_list_column: Optional[ba.Widget] = None self._join_status_text: Optional[ba.Widget] = None self._host_max_party_size_value: Optional[ba.Widget] = None self._host_max_party_size_minus_button: (Optional[ba.Widget]) = None self._host_max_party_size_plus_button: (Optional[ba.Widget]) = None self._host_status_text: Optional[ba.Widget] = None - self._public_parties: Dict[str, PartyEntry] = {} - self._last_list_rebuild_time: Optional[float] = None - self._first_list_rebuild_time: Optional[float] = None + self._parties: Dict[str, PartyEntry] = {} + self._last_server_list_update_time: Optional[float] = None + self._first_server_list_rebuild_time: Optional[float] = None self._next_entry_index = 0 + self._have_valid_server_list = False + self._built_join_list = False def on_activate( self, @@ -234,8 +244,6 @@ class PublicGatherTab(GatherTab): SubTabType.JOIN, region_width, region_height, - region_left, - region_bottom, playsound=True, ), text=ba.Lstr(resource='gatherWindow.' @@ -256,8 +264,6 @@ class PublicGatherTab(GatherTab): SubTabType.HOST, region_width, region_height, - region_left, - region_bottom, playsound=True, ), text=ba.Lstr(resource='gatherWindow.' @@ -268,48 +274,54 @@ class PublicGatherTab(GatherTab): up_widget=tab_button) ba.widget(edit=self._join_text, right_widget=self._host_text) - # Attempt to fetch our local address so we have it for - # error messages. - self._local_address = None - - AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start() + # Attempt to fetch our local address so we have it for error messages. + if self._local_address is None: + AddrFetchThread(ba.WeakCall(self._fetch_local_addr_cb)).start() assert self._sub_tab is not None - self._set_sub_tab(self._sub_tab, region_width, region_height, - region_left, region_bottom) + self._set_sub_tab(self._sub_tab, region_width, region_height) self._update_timer = ba.Timer(0.2, - ba.WeakCall(self._update_sub_tab), + ba.WeakCall(self._update), repeat=True, timetype=ba.TimeType.REAL) - - # Also update it immediately so we don't have to wait for the - # initial query. - self._update_sub_tab() + self._update() return self._container def on_deactivate(self) -> None: self._update_timer = None def save_state(self) -> None: - ba.app.ui.window_states[self.__class__.__name__] = TabState( - sub_tab=self._sub_tab) + + # Save off a small number of parties with the lowest ping; this + # should be most of the ones that matter and will keep things + # a reasonable size. + ba.app.ui.window_states[self.__class__.__name__] = State( + sub_tab=self._sub_tab, + parties=[copy.copy(p) for p in self._get_ordered_parties()[:20]], + next_entry_index=self._next_entry_index) def restore_state(self) -> None: state = ba.app.ui.window_states.get(self.__class__.__name__) if state is None: - state = TabState() - assert isinstance(state, TabState) + state = State() + assert isinstance(state, State) self._sub_tab = state.sub_tab + # Restore the parties we stored... + if state.parties: + self._parties = { + f'{p.address}_{p.port}': copy.copy(p) + for p in state.parties + } + self._next_entry_index = state.next_entry_index + self._have_valid_server_list = True + def _set_sub_tab(self, value: SubTabType, region_width: float, region_height: float, - region_left: float, - region_bottom: float, playsound: bool = False) -> None: assert self._container - del region_left, region_bottom # Unused if playsound: ba.playsound(ba.getsound('click01')) @@ -343,6 +355,21 @@ class PublicGatherTab(GatherTab): if value is SubTabType.JOIN: self._build_join_tab(v, sub_scroll_width, sub_scroll_height, c_width, c_height) + self._built_join_list = False + + # If we've not yet successfully fetched a server list, + # force an attempt now and show the user a 'loading...' status. + if not self._have_valid_server_list: + self._last_server_list_query_time = None + join_status_str = ba.Lstr( + value='${A}...', + subs=[('${A}', ba.Lstr(resource='store.loadingText'))], + ) + else: + # Otherwise we've got valid data already. Show it. + join_status_str = ba.Lstr(value='') + self._update_server_list() + ba.textwidget(edit=self._join_status_text, text=join_status_str) if value is SubTabType.HOST: self._build_host_tab(v, sub_scroll_width, sub_scroll_height, @@ -351,13 +378,6 @@ class PublicGatherTab(GatherTab): def _build_join_tab(self, v: float, sub_scroll_width: float, sub_scroll_height: float, c_width: float, c_height: float) -> None: - # Reset this so we do an immediate refresh query. - self._join_last_refresh_time = -99999.0 - - # Reset our list of public parties. - self._public_parties = {} - self._last_list_rebuild_time = 0 - self._first_list_rebuild_time = None ba.textwidget(text=ba.Lstr(resource='nameText'), parent=self._container, size=(0, 0), @@ -400,26 +420,23 @@ class PublicGatherTab(GatherTab): size=(sub_scroll_width, sub_scroll_height), claims_left_right=True, autoselect=True) - self._host_columnwidget = ba.containerwidget(parent=scrollw, - background=False, - size=(400, 400), - claims_left_right=True) + self._join_list_column = ba.containerwidget(parent=scrollw, + background=False, + size=(400, 400), + claims_left_right=True) - self._join_status_text = ba.textwidget( - parent=self._container, - text=ba.Lstr( - value='${A}...', - subs=[('${A}', ba.Lstr(resource='store.loadingText'))], - ), - 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._join_status_text = ba.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)) def _build_host_tab(self, v: float, sub_scroll_width: float, sub_scroll_height: float, c_width: float, @@ -545,45 +562,48 @@ class PublicGatherTab(GatherTab): if _ba.get_public_party_enabled(): self._do_status_check() - def _rebuild_public_party_list(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - cur_time = ba.time(ba.TimeType.REAL) - if self._first_list_rebuild_time is None: - self._first_list_rebuild_time = cur_time + def _get_ordered_parties(self) -> List[PartyEntry]: + # Sort - show queue-enabled ones first and sort by lowest ping. + ordered_parties = sorted( + self._parties.values(), + key=lambda p: ( + p.queue is None, # Show non-queued last. + p.ping if p.ping is not None else 999999, + p.index)) + return ordered_parties - # Update faster for the first few seconds; + def _update_server_list(self) -> None: + cur_time = ba.time(ba.TimeType.REAL) + if self._first_server_list_rebuild_time is None: + self._first_server_list_rebuild_time = cur_time + + # We get called quite often (for each ping response, etc) so we want + # to limit our rebuilds to keep the UI responsive. + # Let's update faster for the first few seconds, # then ease off to keep the list from jumping around. - since_first = cur_time - self._first_list_rebuild_time + since_first = cur_time - self._first_server_list_rebuild_time wait_time = (1.0 if since_first < 2.0 else 2.5 if since_first < 10.0 else 5.0) - assert self._last_list_rebuild_time is not None - if cur_time - self._last_list_rebuild_time < wait_time: + if (self._built_join_list + and self._last_server_list_update_time is not None + and cur_time - self._last_server_list_update_time < wait_time): return - self._last_list_rebuild_time = cur_time - # First off, check for the existence of our column widget; - # if we don't have this, we're done. - columnwidget = self._host_columnwidget + # If we somehow got here without the required UI being in place... + columnwidget = self._join_list_column if not columnwidget: return + self._last_server_list_update_time = cur_time + self._built_join_list = True + with ba.Context('ui'): # Now kill and recreate all widgets. for widget in columnwidget.get_children(): widget.delete() - # Sort - show queue-enabled ones first and sort by lowest ping. - ordered_parties = sorted( - list(self._public_parties.values()), - key=lambda p: ( - p.queue is None, # Show non-queued last. - p.ping if p.ping is not None else 999999, - p.index)) - existing_selection = self._selection - first = True + ordered_parties = self._get_ordered_parties() sub_scroll_width = 830 lineheight = 42 @@ -602,102 +622,8 @@ class PublicGatherTab(GatherTab): ba.containerwidget(edit=self._host_scrollwidget, claims_up_down=(len(ordered_parties) > 0)) - for i, party in enumerate(ordered_parties): - hpos = 20 - vpos = sub_scroll_height - lineheight * i - 50 - party.name_widget = ba.textwidget( - text=ba.Lstr(value=party.name), - parent=columnwidget, - size=(sub_scroll_width * 0.63, 20), - position=(0 + hpos, 4 + vpos), - selectable=True, - on_select_call=ba.WeakCall( - self._set_public_party_selection, - Selection(party.index, SelectionComponent.NAME)), - on_activate_call=ba.WeakCall( - self._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') - ba.widget(edit=party.name_widget, - left_widget=self._join_text, - show_buffer_top=64.0, - show_buffer_bottom=64.0) - # if existing_selection == (party.address, 'name'): - if existing_selection == Selection(party.index, - SelectionComponent.NAME): - ba.containerwidget(edit=columnwidget, - selected_child=party.name_widget) - if party.stats_addr: - url = party.stats_addr.replace( - '${ACCOUNT}', - _ba.get_account_misc_read_val_2( - 'resolvedAccountID', 'UNKNOWN')) - party.stats_button = ba.buttonwidget( - color=(0.3, 0.6, 0.94), - textcolor=(1.0, 1.0, 1.0), - label=ba.Lstr(resource='statsText'), - parent=columnwidget, - autoselect=True, - on_activate_call=ba.Call(ba.open_url, url), - on_select_call=ba.WeakCall( - self._set_public_party_selection, - Selection(party.index, - SelectionComponent.STATS_BUTTON)), - size=(120, 40), - position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), - scale=0.9) - if existing_selection == Selection( - party.index, SelectionComponent.STATS_BUTTON): - ba.containerwidget(edit=columnwidget, - selected_child=party.stats_button) - else: - if party.stats_button: - party.stats_button.delete() - party.stats_button = None - - if first: - if party.stats_button: - ba.widget(edit=party.stats_button, - up_widget=self._join_text) - if party.name_widget: - ba.widget(edit=party.name_widget, - up_widget=self._join_text) - first = False - - party.size_widget = ba.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') - party.ping_widget = ba.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: - ba.textwidget(edit=party.ping_widget, - text='-', - color=(0.5, 0.5, 0.5)) - else: - ping_good = _ba.get_account_misc_read_val('pingGood', 100) - ping_med = _ba.get_account_misc_read_val('pingMed', 500) - ba.textwidget(edit=party.ping_widget, - text=str(party.ping), - color=(0, 1, - 0) if party.ping <= ping_good else - (1, 1, 0) if party.ping <= ping_med else - (1, 0, 0)) + self._build_server_entry_lines(lineheight, ordered_parties, + sub_scroll_height, sub_scroll_width) # So our selection callbacks can start firing.. def refresh_off() -> None: @@ -705,6 +631,109 @@ class PublicGatherTab(GatherTab): ba.pushcall(refresh_off) + def _build_server_entry_lines(self, lineheight: float, + ordered_parties: List[PartyEntry], + sub_scroll_height: float, + sub_scroll_width: float) -> None: + existing_selection = self._selection + columnwidget = self._join_list_column + first = True + assert columnwidget + for i, party in enumerate(ordered_parties): + hpos = 20 + vpos = sub_scroll_height - lineheight * i - 50 + party.name_widget = ba.textwidget( + text=ba.Lstr(value=party.name), + parent=columnwidget, + size=(sub_scroll_width * 0.63, 20), + position=(0 + hpos, 4 + vpos), + selectable=True, + on_select_call=ba.WeakCall( + self._set_public_party_selection, + Selection(party.index, SelectionComponent.NAME)), + on_activate_call=ba.WeakCall(self._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') + ba.widget(edit=party.name_widget, + left_widget=self._join_text, + show_buffer_top=64.0, + show_buffer_bottom=64.0) + if existing_selection == Selection(party.index, + SelectionComponent.NAME): + ba.containerwidget(edit=columnwidget, + selected_child=party.name_widget) + if party.stats_addr: + url = party.stats_addr.replace( + '${ACCOUNT}', + _ba.get_account_misc_read_val_2('resolvedAccountID', + 'UNKNOWN')) + party.stats_button = ba.buttonwidget( + color=(0.3, 0.6, 0.94), + textcolor=(1.0, 1.0, 1.0), + label=ba.Lstr(resource='statsText'), + parent=columnwidget, + autoselect=True, + on_activate_call=ba.Call(ba.open_url, url), + on_select_call=ba.WeakCall( + self._set_public_party_selection, + Selection(party.index, + SelectionComponent.STATS_BUTTON)), + size=(120, 40), + position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), + scale=0.9) + if existing_selection == Selection( + party.index, SelectionComponent.STATS_BUTTON): + ba.containerwidget(edit=columnwidget, + selected_child=party.stats_button) + else: + if party.stats_button: + party.stats_button.delete() + party.stats_button = None + + if first: + if party.stats_button: + ba.widget(edit=party.stats_button, + up_widget=self._join_text) + if party.name_widget: + ba.widget(edit=party.name_widget, + up_widget=self._join_text) + first = False + + party.size_widget = ba.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') + party.ping_widget = ba.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: + ba.textwidget(edit=party.ping_widget, + text='-', + color=(0.5, 0.5, 0.5)) + else: + ping_good = _ba.get_account_misc_read_val('pingGood', 100) + ping_med = _ba.get_account_misc_read_val('pingMed', 500) + ba.textwidget(edit=party.ping_widget, + text=str(party.ping), + color=(0, 1, 0) if party.ping <= ping_good else + (1, 1, 0) if party.ping <= ping_med else + (1, 0, 0)) + def _on_public_party_query_result( self, result: Optional[Dict[str, Any]]) -> None: with ba.Context('ui'): @@ -724,11 +753,13 @@ class PublicGatherTab(GatherTab): ba.textwidget(edit=status_text, text='') if result is not None: + self._have_valid_server_list = True parties_in = result['l'] else: + self._have_valid_server_list = False parties_in = [] - for partyval in list(self._public_parties.values()): + for partyval in list(self._parties.values()): partyval.claimed = False for party_in in parties_in: @@ -737,14 +768,13 @@ class PublicGatherTab(GatherTab): port = party_in['p'] assert isinstance(port, int) party_key = f'{addr}_{port}' - party = self._public_parties.get(party_key) + party = self._parties.get(party_key) if party is None: # If this party is new to us, init it. - party = self._public_parties[party_key] = PartyEntry( + party = self._parties[party_key] = PartyEntry( address=addr, next_ping_time=ba.time(ba.TimeType.REAL) + 0.001 * party_in['pd'], - ping=None, index=self._next_entry_index) self._next_entry_index += 1 assert isinstance(party.address, str) @@ -768,14 +798,14 @@ class PublicGatherTab(GatherTab): assert isinstance(party.stats_addr, (str, type(None))) # Prune unclaimed party entries. - self._public_parties = { + self._parties = { key: val - for key, val in list(self._public_parties.items()) - if val.claimed + for key, val in list(self._parties.items()) if val.claimed } - self._rebuild_public_party_list() + self._update_server_list() - def _update_sub_tab(self) -> None: + def _update(self) -> None: + """Periodic updating.""" # Special case: if a party-queue window is up, don't do any of this # (keeps things smoother). @@ -791,35 +821,50 @@ class PublicGatherTab(GatherTab): if self._sub_tab is SubTabType.JOIN: now = ba.time(ba.TimeType.REAL) - if (now - self._join_last_refresh_time > 0.001 * + + # 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 * _ba.get_account_misc_read_val('pubPartyRefreshMS', 10000)): - self._join_last_refresh_time = now - app = ba.app + self._last_server_list_query_time = now + if DEBUG_SERVER_COMMUNICATION: + print('REQUESTING SERVER LIST') _ba.add_transaction( { 'type': 'PUBLIC_PARTY_QUERY', - 'proto': app.protocol_version, - 'lang': app.lang.language + 'proto': ba.app.protocol_version, + 'lang': ba.app.lang.language }, callback=ba.WeakCall(self._on_public_party_query_result)) _ba.run_transactions() # Go through our existing public party entries firing off pings # for any that have timed out. - for party in list(self._public_parties.values()): + for party in list(self._parties.values()): if (party.next_ping_time <= now and ba.app.ping_thread_count < 15): - # Make sure to fully catch up and not to multi-ping if - # we're way behind somehow. - while party.next_ping_time <= now: - # Crank the interval up for high-latency parties to - # save us some work. - mult = 1 - if party.ping is not None: - mult = (10 if party.ping > 300 else - 5 if party.ping > 150 else 2) - party.next_ping_time += party.ping_interval * mult + # 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, ba.WeakCall(self._ping_callback)).start() @@ -828,8 +873,12 @@ class PublicGatherTab(GatherTab): result: Optional[int]) -> None: # Look for a widget corresponding to this target. # If we find one, update our list. - party = self._public_parties.get(address + '_' + str(port)) + 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 @@ -840,11 +889,7 @@ class PublicGatherTab(GatherTab): (1.0 - smoothing) * result) else: party.ping = result - - # This can happen if we switch away and then back to the - # client tab while pings are in flight. - if party.ping_widget: - self._rebuild_public_party_list() + self._update_server_list() def _fetch_local_addr_cb(self, val: str) -> None: self._local_address = str(val)