diff --git a/dist/ba_data/python/ba/_app.py b/dist/ba_data/python/ba/_app.py index b6d2e1a..bc197d3 100644 --- a/dist/ba_data/python/ba/_app.py +++ b/dist/ba_data/python/ba/_app.py @@ -3,8 +3,9 @@ """Functionality related to the high level state of the app.""" from __future__ import annotations -from enum import Enum import random +import logging +from enum import Enum from typing import TYPE_CHECKING import _ba @@ -184,6 +185,9 @@ class App: self.state = self.State.LAUNCHING + self._app_launched = False + self._app_paused = False + # Config. self.config_file_healthy = False @@ -348,27 +352,6 @@ class App: for key in ('lc14173', 'lc14292'): cfg.setdefault(key, launch_count) - # Debugging - make note if we're using the local test server so we - # don't accidentally leave it on in a release. - # FIXME - should move this to the native layer. - server_addr = _ba.get_master_server_address() - if 'localhost' in server_addr: - _ba.timer(2.0, - lambda: _ba.screenmessage( - 'Note: using local server', - (1, 1, 0), - log=True, - ), - timetype=TimeType.REAL) - elif 'test' in server_addr: - _ba.timer(2.0, - lambda: _ba.screenmessage( - 'Note: using test server-module', - (1, 1, 0), - log=True, - ), - timetype=TimeType.REAL) - cfg['launchCount'] = launch_count cfg.commit() @@ -389,20 +372,40 @@ class App: self.accounts.on_app_launch() self.plugins.on_app_launch() - self.state = self.State.RUNNING + # See note below in on_app_pause. + if self.state != self.State.LAUNCHING: + logging.error('on_app_launch found state %s; expected LAUNCHING.', + self.state) + + self._app_launched = True + self._update_state() # from ba._dependency import test_depset # test_depset() + if bool(False): + self._test_https() + + def _update_state(self) -> None: + if self._app_paused: + self.state = self.State.PAUSED + else: + if self._app_launched: + self.state = self.State.RUNNING + else: + self.state = self.State.LAUNCHING def on_app_pause(self) -> None: """Called when the app goes to a suspended state.""" - self.state = self.State.PAUSED + + self._app_paused = True + self._update_state() self.plugins.on_app_pause() def on_app_resume(self) -> None: """Run when the app resumes from a suspended state.""" - self.state = self.State.RUNNING + self._app_paused = False + self._update_state() self.fg_state += 1 self.accounts.on_app_resume() self.music.on_app_resume() @@ -586,6 +589,8 @@ class App: try: with urllib.request.urlopen('https://example.com') as url: val = url.read() + _ba.screenmessage('HTTPS SUCCESS!') print('HTTPS TEST SUCCESS', len(val)) except Exception as exc: + _ba.screenmessage('HTTPS FAIL.') print('HTTPS TEST FAIL:', exc) diff --git a/dist/ba_data/python/ba/_asyncio.py b/dist/ba_data/python/ba/_asyncio.py index 76206e6..79407a7 100644 --- a/dist/ba_data/python/ba/_asyncio.py +++ b/dist/ba_data/python/ba/_asyncio.py @@ -60,12 +60,13 @@ def setup_asyncio() -> None: timetype=TimeType.REAL, repeat=True) - async def aio_test() -> None: - print('TEST AIO TASK STARTING') - assert _asyncio_event_loop is not None - assert asyncio.get_running_loop() is _asyncio_event_loop - await asyncio.sleep(2.0) - print('TEST AIO TASK ENDING') - if bool(False): + + async def aio_test() -> None: + print('TEST AIO TASK STARTING') + assert _asyncio_event_loop is not None + assert asyncio.get_running_loop() is _asyncio_event_loop + await asyncio.sleep(2.0) + print('TEST AIO TASK ENDING') + _asyncio_event_loop.create_task(aio_test()) diff --git a/dist/ba_data/python/ba/_dependency.py b/dist/ba_data/python/ba/_dependency.py index b6663a0..87a3116 100644 --- a/dist/ba_data/python/ba/_dependency.py +++ b/dist/ba_data/python/ba/_dependency.py @@ -148,7 +148,7 @@ class DependencyEntry: instance = self.cls.__new__(self.cls) # pylint: disable=protected-access instance._dep_entry = weakref.ref(self) - instance.__init__() + instance.__init__() # type: ignore assert self.depset depset = self.depset() diff --git a/dist/ba_data/python/ba/_enums.py b/dist/ba_data/python/ba/_enums.py index ea937b5..74a11bb 100644 --- a/dist/ba_data/python/ba/_enums.py +++ b/dist/ba_data/python/ba/_enums.py @@ -196,3 +196,4 @@ class SpecialChar(Enum): FLAG_PHILIPPINES = 87 FLAG_CHILE = 88 MIKIROG = 89 + V2_LOGO = 90 diff --git a/dist/ba_data/python/ba/_generated/enums.py b/dist/ba_data/python/ba/_generated/enums.py index ea937b5..74a11bb 100644 --- a/dist/ba_data/python/ba/_generated/enums.py +++ b/dist/ba_data/python/ba/_generated/enums.py @@ -196,3 +196,4 @@ class SpecialChar(Enum): FLAG_PHILIPPINES = 87 FLAG_CHILE = 88 MIKIROG = 89 + V2_LOGO = 90 diff --git a/dist/ba_data/python/ba/_hooks.py b/dist/ba_data/python/ba/_hooks.py index 4a1d0a1..61a721a 100644 --- a/dist/ba_data/python/ba/_hooks.py +++ b/dist/ba_data/python/ba/_hooks.py @@ -119,6 +119,11 @@ def gear_vr_controller_warning() -> None: color=(1, 0, 0)) +def uuid_str() -> str: + import uuid + return str(uuid.uuid4()) + + def orientation_reset_cb_message() -> None: from ba._language import Lstr _ba.screenmessage( @@ -370,3 +375,13 @@ def get_player_icon(sessionplayer: ba.SessionPlayer) -> dict[str, Any]: 'tint_color': info['tint_color'], 'tint2_color': info['tint2_color'] } + + +def hash_strings(inputs: list[str]) -> str: + """Hash provided strings into a short output string.""" + import hashlib + sha = hashlib.sha1() + for inp in inputs: + sha.update(inp.encode()) + + return sha.hexdigest() diff --git a/dist/ba_data/python/ba/_language.py b/dist/ba_data/python/ba/_language.py index 5709b6e..b637bb4 100644 --- a/dist/ba_data/python/ba/_language.py +++ b/dist/ba_data/python/ba/_language.py @@ -87,6 +87,7 @@ class LanguageSubsystem: 'vec': 'Venetian', 'hi': 'Hindi', 'ta': 'Tamil', + 'fil': 'Filipino', } # Special case for Chinese: map specific variations to traditional. @@ -373,7 +374,7 @@ class Lstr: currently-active language. To see available resource keys, look at any of the bs_language_*.py files - in the game or the translations pages at bombsquadgame.com/translate. + in the game or the translations pages at legacy.ballistica.net/translate. # EXAMPLE 1: specify a string from a resource path mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText') diff --git a/dist/ba_data/python/ba/_net.py b/dist/ba_data/python/ba/_net.py index c27f3db..d427ccc 100644 --- a/dist/ba_data/python/ba/_net.py +++ b/dist/ba_data/python/ba/_net.py @@ -25,8 +25,15 @@ class NetworkSubsystem: """Network related app subsystem.""" def __init__(self) -> None: + + # Anyone accessing/modifying region_pings should hold this lock. + self.region_pings_lock = threading.Lock() self.region_pings: dict[str, float] = {} + # For debugging. + self.v1_test_log: str = '' + self.v1_ctest_results: dict[int, str] = {} + def get_ip_address_type(addr: str) -> socket.AddressFamily: """Return socket.AF_INET6 or socket.AF_INET4 for the provided address.""" diff --git a/dist/ba_data/python/bacommon/assets.py b/dist/ba_data/python/bacommon/assets.py index 311852d..097b52f 100644 --- a/dist/ba_data/python/bacommon/assets.py +++ b/dist/ba_data/python/bacommon/assets.py @@ -38,8 +38,8 @@ class AssetType(Enum): @dataclass class AssetPackageFlavorManifest: """A manifest of asset info for a specific flavor of an asset package.""" - assetfiles: Annotated[dict[str, str], - IOAttrs('assetfiles')] = field(default_factory=dict) + cloudfiles: Annotated[dict[str, str], + IOAttrs('cloudfiles')] = field(default_factory=dict) @ioprepped diff --git a/dist/ba_data/python/bacommon/build.py b/dist/ba_data/python/bacommon/build.py new file mode 100644 index 0000000..44ce489 --- /dev/null +++ b/dist/ba_data/python/bacommon/build.py @@ -0,0 +1,33 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Functionality related to game builds.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Annotated + +from efro.dataclassio import ioprepped, IOAttrs + +if TYPE_CHECKING: + pass + + +@ioprepped +@dataclass +class BuildInfoSet: + """Set of build infos.""" + + @dataclass + class Entry: + """Info about a particular build.""" + filename: Annotated[str, IOAttrs('fname')] + size: Annotated[int, IOAttrs('size')] + version: Annotated[str, IOAttrs('version')] + build_number: Annotated[int, IOAttrs('build')] + checksum: Annotated[str, IOAttrs('checksum')] + createtime: Annotated[datetime.datetime, IOAttrs('createtime')] + + builds: Annotated[list[Entry], + IOAttrs('builds')] = field(default_factory=list) diff --git a/dist/ba_data/python/bacommon/servermanager.py b/dist/ba_data/python/bacommon/servermanager.py index b767006..5516dbb 100644 --- a/dist/ba_data/python/bacommon/servermanager.py +++ b/dist/ba_data/python/bacommon/servermanager.py @@ -104,7 +104,7 @@ class ServerConfig: # if ${ACCOUNT} is present in the string, it will be replaced by the # currently-signed-in account's id. To fetch info about an account, # your back-end server can use the following url: - # http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE + # https://legacy.ballistica.net/accountquery?id=ACCOUNT_ID_HERE stats_url: Optional[str] = None # If present, the server subprocess will attempt to gracefully exit after diff --git a/dist/ba_data/python/bastd/actor/spawner.py b/dist/ba_data/python/bastd/actor/spawner.py index e40d186..b1ed9c1 100644 --- a/dist/ba_data/python/bastd/actor/spawner.py +++ b/dist/ba_data/python/bastd/actor/spawner.py @@ -39,18 +39,24 @@ class Spawner: The spawn position. """ - def __init__(self, spawner: Spawner, data: Any, pt: Sequence[float]): + def __init__( + self, + spawner: Spawner, + data: Any, + pt: Sequence[float], # pylint: disable=invalid-name + ): """Instantiate with the given values.""" self.spawner = spawner self.data = data self.pt = pt # pylint: disable=invalid-name - def __init__(self, - data: Any = None, - pt: Sequence[float] = (0, 0, 0), - spawn_time: float = 1.0, - send_spawn_message: bool = True, - spawn_callback: Callable[[], Any] = None): + def __init__( + self, + data: Any = None, + pt: Sequence[float] = (0, 0, 0), # pylint: disable=invalid-name + spawn_time: float = 1.0, + send_spawn_message: bool = True, + spawn_callback: Callable[[], Any] = None): """Instantiate a Spawner. Requires some custom data, a position, diff --git a/dist/ba_data/python/bastd/actor/spaz.py b/dist/ba_data/python/bastd/actor/spaz.py index 45931bb..b1d42f3 100644 --- a/dist/ba_data/python/bastd/actor/spaz.py +++ b/dist/ba_data/python/bastd/actor/spaz.py @@ -199,6 +199,7 @@ class Spaz(ba.Actor): self.equip_boxing_gloves() self.last_punch_time_ms = -9999 self.last_pickup_time_ms = -9999 + self.last_jump_time_ms = -9999 self.last_run_time_ms = -9999 self._last_run_value = 0.0 self.last_bomb_time_ms = -9999 @@ -363,7 +364,11 @@ class Spaz(ba.Actor): """ if not self.node: return - self.node.jump_pressed = True + t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) + assert isinstance(t_ms, int) + if t_ms - self.last_jump_time_ms >= self._jump_cooldown: + self.node.jump_pressed = True + self.last_jump_time_ms = t_ms self._turbo_filter_add_press('jump') def on_jump_release(self) -> None: diff --git a/dist/ba_data/python/bastd/gameutils.py b/dist/ba_data/python/bastd/gameutils.py index 01a913f..89d7ed4 100644 --- a/dist/ba_data/python/bastd/gameutils.py +++ b/dist/ba_data/python/bastd/gameutils.py @@ -26,7 +26,7 @@ class SharedObjects: def __init__(self) -> None: activity = ba.getactivity() - if hasattr(activity, self._STORENAME): + if self._STORENAME in activity.customdata: raise RuntimeError('Use SharedObjects.get() to fetch the' ' shared instance for this activity.') self._object_material: Optional[ba.Material] = None diff --git a/dist/ba_data/python/bastd/mainmenu.py b/dist/ba_data/python/bastd/mainmenu.py index e7f2db6..86818e2 100644 --- a/dist/ba_data/python/bastd/mainmenu.py +++ b/dist/ba_data/python/bastd/mainmenu.py @@ -60,7 +60,7 @@ class MainMenuActivity(ba.Activity[ba.Player, ba.Team]): 'scale': scale, 'position': (0, 10), 'vr_depth': -10, - 'text': '\xa9 2011-2021 Eric Froemling' + 'text': '\xa9 2011-2022 Eric Froemling' })) # Throw up some text that only clients can see so they know that the diff --git a/dist/ba_data/python/bastd/ui/account/__init__.py b/dist/ba_data/python/bastd/ui/account/__init__.py index 9b1c446..7bf7c13 100644 --- a/dist/ba_data/python/bastd/ui/account/__init__.py +++ b/dist/ba_data/python/bastd/ui/account/__init__.py @@ -10,17 +10,17 @@ import ba def show_sign_in_prompt(account_type: str = None) -> None: """Bring up a prompt telling the user they must sign in.""" - from bastd.ui import confirm + from bastd.ui.confirm import ConfirmWindow from bastd.ui.account import settings if account_type == 'Google Play': - confirm.ConfirmWindow( + ConfirmWindow( ba.Lstr(resource='notSignedInGooglePlayErrorText'), lambda: _ba.sign_in('Google Play'), ok_text=ba.Lstr(resource='accountSettingsWindow.signInText'), width=460, height=130) else: - confirm.ConfirmWindow( + ConfirmWindow( ba.Lstr(resource='notSignedInErrorText'), lambda: settings.AccountSettingsWindow(modal=True, close_once_signed_in=True), diff --git a/dist/ba_data/python/bastd/ui/account/settings.py b/dist/ba_data/python/bastd/ui/account/settings.py index 3e517b1..1a7d264 100644 --- a/dist/ba_data/python/bastd/ui/account/settings.py +++ b/dist/ba_data/python/bastd/ui/account/settings.py @@ -25,6 +25,10 @@ class AccountSettingsWindow(ba.Window): close_once_signed_in: bool = False): # pylint: disable=too-many-statements + self._sign_in_game_circle_button: Optional[ba.Widget] = None + self._sign_in_v2_button: Optional[ba.Widget] = None + self._sign_in_device_button: Optional[ba.Widget] = None + self._close_once_signed_in = close_once_signed_in ba.set_analytics_screen('Account Window') @@ -86,6 +90,10 @@ class AccountSettingsWindow(ba.Window): # exceptions. self._show_sign_in_buttons.append('Local') + # Ditto with shiny new V2 ones. + if bool(False): + self._show_sign_in_buttons.append('V2') + top_extra = 15 if uiscale is ba.UIScale.SMALL else 0 super().__init__(root_widget=ba.containerwidget( size=(self._width, self._height + top_extra), @@ -206,12 +214,10 @@ class AccountSettingsWindow(ba.Window): show_game_circle_sign_in_button = (account_state == 'signed_out' and 'Game Circle' in self._show_sign_in_buttons) - show_ali_sign_in_button = (account_state == 'signed_out' - and 'Ali' in self._show_sign_in_buttons) - show_test_sign_in_button = (account_state == 'signed_out' - and 'Test' in self._show_sign_in_buttons) show_device_sign_in_button = (account_state == 'signed_out' and 'Local' in self._show_sign_in_buttons) + show_v2_sign_in_button = (account_state == 'signed_out' + and 'V2' in self._show_sign_in_buttons) sign_in_button_space = 70.0 show_game_service_button = (self._signed_in and account_type @@ -223,9 +229,9 @@ class AccountSettingsWindow(ba.Window): 'allowAccountLinking2', False)) linked_accounts_text_space = 60.0 - show_achievements_button = (self._signed_in and account_type - in ('Google Play', 'Alibaba', 'Local', - 'OUYA', 'Test')) + show_achievements_button = ( + self._signed_in + and account_type in ('Google Play', 'Alibaba', 'Local', 'OUYA')) achievements_button_space = 60.0 show_achievements_text = (self._signed_in @@ -255,8 +261,8 @@ class AccountSettingsWindow(ba.Window): show_unlink_accounts_button = show_link_accounts_button unlink_accounts_button_space = 90.0 - show_sign_out_button = (self._signed_in and account_type - in ['Test', 'Local', 'Google Play']) + show_sign_out_button = (self._signed_in + and account_type in ['Local', 'Google Play']) sign_out_button_space = 70.0 if self._subcontainer is not None: @@ -272,12 +278,10 @@ class AccountSettingsWindow(ba.Window): self._sub_height += sign_in_button_space if show_game_circle_sign_in_button: self._sub_height += sign_in_button_space - if show_ali_sign_in_button: - self._sub_height += sign_in_button_space - if show_test_sign_in_button: - self._sub_height += sign_in_button_space if show_device_sign_in_button: self._sub_height += sign_in_button_space + if show_v2_sign_in_button: + self._sub_height += sign_in_button_space if show_game_service_button: self._sub_height += game_service_button_space if show_linked_accounts_text: @@ -462,21 +466,42 @@ class AccountSettingsWindow(ba.Window): ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None - if show_ali_sign_in_button: + if show_v2_sign_in_button: button_width = 350 v -= sign_in_button_space - self._sign_in_ali_button = btn = ba.buttonwidget( + self._sign_in_v2_button = btn = ba.buttonwidget( parent=self._subcontainer, position=((self._sub_width - button_width) * 0.5, v - 20), autoselect=True, size=(button_width, 60), - label=ba.Lstr(value='${A}${B}', - subs=[('${A}', - ba.charstr(ba.SpecialChar.ALIBABA_LOGO)), - ('${B}', - ba.Lstr(resource=self._r + '.signInText')) - ]), - on_activate_call=lambda: self._sign_in_press('Ali')) + label='', + on_activate_call=self._v2_sign_in_press) + ba.textwidget( + parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 17), + text=ba.Lstr( + value='${A}${B}', + subs=[('${A}', ba.charstr(ba.SpecialChar.V2_LOGO)), + ('${B}', + ba.Lstr(resource=self._r + '.signInWithV2Text'))]), + maxwidth=button_width * 0.8, + color=(0.75, 1.0, 0.7)) + ba.textwidget(parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v - 4), + text=ba.Lstr(resource=self._r + + '.signInWithV2InfoText'), + flatness=1.0, + scale=0.57, + maxwidth=button_width * 0.9, + color=(0.55, 0.8, 0.5)) if first_selectable is None: first_selectable = btn if ba.app.ui.use_toolbars: @@ -532,53 +557,6 @@ class AccountSettingsWindow(ba.Window): ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None - # Old test-account option. - if show_test_sign_in_button: - button_width = 350 - v -= sign_in_button_space - self._sign_in_test_button = btn = ba.buttonwidget( - parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v - 20), - autoselect=True, - size=(button_width, 60), - label='', - on_activate_call=lambda: self._sign_in_press('Test')) - ba.textwidget(parent=self._subcontainer, - draw_controller=btn, - h_align='center', - v_align='center', - size=(0, 0), - position=(self._sub_width * 0.5, v + 17), - text=ba.Lstr( - value='${A}${B}', - subs=[('${A}', - ba.charstr(ba.SpecialChar.TEST_ACCOUNT)), - ('${B}', - ba.Lstr(resource=self._r + - '.signInWithTestAccountText'))]), - maxwidth=button_width * 0.8, - color=(0.75, 1.0, 0.7)) - ba.textwidget(parent=self._subcontainer, - draw_controller=btn, - h_align='center', - v_align='center', - size=(0, 0), - position=(self._sub_width * 0.5, v - 4), - text=ba.Lstr(resource=self._r + - '.signInWithTestAccountInfoText'), - flatness=1.0, - scale=0.57, - maxwidth=button_width * 0.9, - color=(0.55, 0.8, 0.5)) - if first_selectable is None: - first_selectable = btn - if ba.app.ui.use_toolbars: - ba.widget(edit=btn, - right_widget=_ba.get_special_widget('party_button')) - ba.widget(edit=btn, left_widget=bbtn) - ba.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) - self._sign_in_text = None - if show_player_profiles_button: button_width = 300 v -= player_profiles_button_space @@ -1051,6 +1029,12 @@ class AccountSettingsWindow(ba.Window): self._needs_refresh = True ba.timer(0.1, ba.WeakCall(self._update), timetype=ba.TimeType.REAL) + def _v2_sign_in_press(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.account.v2 import V2SignInWindow + assert self._sign_in_v2_button is not None + V2SignInWindow(origin_widget=self._sign_in_v2_button) + def _reset_progress(self) -> None: try: from ba.internal import getcampaign diff --git a/dist/ba_data/python/bastd/ui/account/v2.py b/dist/ba_data/python/bastd/ui/account/v2.py new file mode 100644 index 0000000..739edd4 --- /dev/null +++ b/dist/ba_data/python/bastd/ui/account/v2.py @@ -0,0 +1,92 @@ +# Released under the MIT License. See LICENSE for details. +# +"""V2 account ui bits.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import ba +import _ba + +if TYPE_CHECKING: + from typing import Any, Optional + + +class V2SignInWindow(ba.Window): + """A window allowing signing in to a v2 account.""" + + def __init__(self, origin_widget: ba.Widget): + from ba.internal import is_browser_likely_available + logincode = '1412345' + address = ( + f'{_ba.get_master_server_address(version=2)}?login={logincode}') + self._width = 600 + self._height = 500 + uiscale = ba.app.ui.uiscale + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + transition='in_scale', + scale_origin_stack_offset=origin_widget.get_screen_space_center(), + scale=(1.25 if uiscale is ba.UIScale.SMALL else + 1.0 if uiscale is ba.UIScale.MEDIUM else 0.85))) + + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 85), + size=(0, 0), + text=ba.Lstr( + resource='accountSettingsWindow.v2LinkInstructionsText'), + color=ba.app.ui.title_color, + maxwidth=self._width * 0.9, + h_align='center', + v_align='center') + button_width = 450 + if is_browser_likely_available(): + ba.buttonwidget(parent=self._root_widget, + position=((self._width * 0.5 - button_width * 0.5), + self._height - 175), + autoselect=True, + size=(button_width, 60), + label=ba.Lstr(value=address), + color=(0.55, 0.5, 0.6), + textcolor=(0.75, 0.7, 0.8), + on_activate_call=lambda: ba.open_url(address)) + qroffs = 0.0 + else: + ba.textwidget(parent=self._root_widget, + position=(self._width * 0.5, self._height - 135), + size=(0, 0), + text=ba.Lstr(value=address), + flatness=1.0, + maxwidth=self._width, + scale=0.75, + h_align='center', + v_align='center') + qroffs = 20.0 + + self._cancel_button = ba.buttonwidget( + parent=self._root_widget, + position=(30, self._height - 55), + size=(130, 50), + scale=0.8, + label=ba.Lstr(resource='cancelText'), + # color=(0.6, 0.5, 0.6), + on_activate_call=self._done, + autoselect=True, + textcolor=(0.75, 0.7, 0.8), + # icon=ba.gettexture('crossOut'), + # iconscale=1.2 + ) + ba.containerwidget(edit=self._root_widget, + cancel_button=self._cancel_button) + + qr_size = 270 + ba.imagewidget(parent=self._root_widget, + position=(self._width * 0.5 - qr_size * 0.5, + self._height * 0.34 + qroffs - qr_size * 0.5), + size=(qr_size, qr_size), + texture=_ba.get_qrcode_texture(address)) + + def _done(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/dist/ba_data/python/bastd/ui/gather/publictab.py b/dist/ba_data/python/bastd/ui/gather/publictab.py index b2c1b64..2edb995 100644 --- a/dist/ba_data/python/bastd/ui/gather/publictab.py +++ b/dist/ba_data/python/bastd/ui/gather/publictab.py @@ -1209,6 +1209,7 @@ class PublicGatherTab(GatherTab): 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 bastd.ui.partyqueue import PartyQueueWindow ba.playsound(ba.getsound('swish')) diff --git a/dist/ba_data/python/bastd/ui/party.py b/dist/ba_data/python/bastd/ui/party.py index 307b570..f0b65d3 100644 --- a/dist/ba_data/python/bastd/ui/party.py +++ b/dist/ba_data/python/bastd/ui/party.py @@ -5,7 +5,6 @@ from __future__ import annotations import math -import weakref from typing import TYPE_CHECKING, cast import _ba @@ -413,53 +412,3 @@ class PartyWindow(ba.Window): """Close the window and make a lovely sound.""" ba.playsound(ba.getsound('swish')) self.close() - - -def handle_party_invite(name: str, invite_id: str) -> None: - """Handle an incoming party invitation.""" - from bastd import mainmenu - from bastd.ui import confirm - ba.playsound(ba.getsound('fanfare')) - - # if we're not in the main menu, just print the invite - # (don't want to screw up an in-progress game) - in_game = not isinstance(_ba.get_foreground_host_session(), - mainmenu.MainMenuSession) - if in_game: - ba.screenmessage(ba.Lstr( - value='${A}\n${B}', - subs=[('${A}', - ba.Lstr(resource='gatherWindow.partyInviteText', - subs=[('${NAME}', name)])), - ('${B}', - ba.Lstr( - resource='gatherWindow.partyInviteGooglePlayExtraText')) - ]), - color=(0.5, 1, 0)) - else: - - def do_accept(inv_id: str) -> None: - _ba.accept_party_invitation(inv_id) - - conf = confirm.ConfirmWindow( - ba.Lstr(resource='gatherWindow.partyInviteText', - subs=[('${NAME}', name)]), - ba.Call(do_accept, invite_id), - width=500, - height=150, - color=(0.75, 1.0, 0.0), - ok_text=ba.Lstr(resource='gatherWindow.partyInviteAcceptText'), - cancel_text=ba.Lstr(resource='gatherWindow.partyInviteIgnoreText')) - - # FIXME: Ugly. - # Let's store the invite-id away on the confirm window so we know if - # we need to kill it later. - conf.party_invite_id = invite_id # type: ignore - - # store a weak-ref so we can get at this later - ba.app.invite_confirm_windows.append(weakref.ref(conf)) - - # go ahead and prune our weak refs while we're here. - ba.app.invite_confirm_windows = [ - w for w in ba.app.invite_confirm_windows if w() is not None - ] diff --git a/dist/ba_data/python/bastd/ui/settings/advanced.py b/dist/ba_data/python/bastd/ui/settings/advanced.py index d281b4f..73a1338 100644 --- a/dist/ba_data/python/bastd/ui/settings/advanced.py +++ b/dist/ba_data/python/bastd/ui/settings/advanced.py @@ -322,8 +322,8 @@ class AdvancedSettingsWindow(ba.Window): subs=[('${APP_NAME}', ba.Lstr(resource='titleText')) ]), autoselect=True, - on_activate_call=ba.Call(ba.open_url, - 'http://bombsquadgame.com/translate')) + on_activate_call=ba.Call( + ba.open_url, 'https://legacy.ballistica.net/translate')) self._lang_status_text = ba.textwidget(parent=self._subcontainer, position=(self._sub_width * 0.5, diff --git a/dist/ba_data/python/bastd/ui/settings/controls.py b/dist/ba_data/python/bastd/ui/settings/controls.py index 81aa071..3da6c7c 100644 --- a/dist/ba_data/python/bastd/ui/settings/controls.py +++ b/dist/ba_data/python/bastd/ui/settings/controls.py @@ -91,21 +91,6 @@ class ControlsSettingsWindow(ba.Window): else: show_remote = False - show_ps3 = False - # if platform == 'mac': - # show_ps3 = True - # height += spacing - - show360 = False - # if platform == 'mac' or is_fire_tv: - # show360 = True - # height += spacing - - show_mac_wiimote = False - # if platform == 'mac' and _ba.is_xcode_build(): - # show_mac_wiimote = True - # height += spacing - # On windows (outside of oculus/vr), show an option to disable xinput. show_xinput_toggle = False if platform == 'windows' and not app.vr_mode: @@ -152,9 +137,6 @@ class ControlsSettingsWindow(ba.Window): self._keyboard_button: Optional[ba.Widget] = None self._keyboard_2_button: Optional[ba.Widget] = None self._idevices_button: Optional[ba.Widget] = None - self._ps3_button: Optional[ba.Widget] = None - self._xbox_360_button: Optional[ba.Widget] = None - self._wiimotes_button: Optional[ba.Widget] = None ba.textwidget(parent=self._root_widget, position=(0, height - 49), @@ -261,42 +243,6 @@ class ControlsSettingsWindow(ba.Window): down_widget=self._idevices_button) self._have_selected_child = True v -= spacing - if show_ps3: - self._ps3_button = btn = ba.buttonwidget( - parent=self._root_widget, - position=((width - button_width) / 2 + 5, v), - size=(button_width, 43), - autoselect=True, - label=ba.Lstr(resource=self._r + '.ps3Text'), - on_activate_call=self._do_ps3_controllers) - if ba.app.ui.use_toolbars: - ba.widget(edit=btn, - right_widget=_ba.get_special_widget('party_button')) - v -= spacing - if show360: - self._xbox_360_button = btn = ba.buttonwidget( - parent=self._root_widget, - position=((width - button_width) / 2 - 1, v), - size=(button_width, 43), - autoselect=True, - label=ba.Lstr(resource=self._r + '.xbox360Text'), - on_activate_call=self._do_360_controllers) - if ba.app.ui.use_toolbars: - ba.widget(edit=btn, - right_widget=_ba.get_special_widget('party_button')) - v -= spacing - if show_mac_wiimote: - self._wiimotes_button = btn = ba.buttonwidget( - parent=self._root_widget, - position=((width - button_width) / 2 + 5, v), - size=(button_width, 43), - autoselect=True, - label=ba.Lstr(resource=self._r + '.wiimotesText'), - on_activate_call=self._do_wiimotes) - if ba.app.ui.use_toolbars: - ba.widget(edit=btn, - right_widget=_ba.get_special_widget('party_button')) - v -= spacing if show_xinput_toggle: @@ -397,31 +343,6 @@ class ControlsSettingsWindow(ba.Window): ba.app.ui.set_main_menu_window( RemoteAppSettingsWindow().get_root_widget()) - def _do_ps3_controllers(self) -> None: - # pylint: disable=cyclic-import - from bastd.ui.settings.ps3controller import PS3ControllerSettingsWindow - self._save_state() - ba.containerwidget(edit=self._root_widget, transition='out_left') - ba.app.ui.set_main_menu_window( - PS3ControllerSettingsWindow().get_root_widget()) - - def _do_360_controllers(self) -> None: - # pylint: disable=cyclic-import - from bastd.ui.settings.xbox360controller import ( - XBox360ControllerSettingsWindow) - self._save_state() - ba.containerwidget(edit=self._root_widget, transition='out_left') - ba.app.ui.set_main_menu_window( - XBox360ControllerSettingsWindow().get_root_widget()) - - def _do_wiimotes(self) -> None: - # pylint: disable=cyclic-import - from bastd.ui.settings.wiimote import WiimoteSettingsWindow - self._save_state() - ba.containerwidget(edit=self._root_widget, transition='out_left') - ba.app.ui.set_main_menu_window( - WiimoteSettingsWindow().get_root_widget()) - def _do_gamepads(self) -> None: # pylint: disable=cyclic-import from bastd.ui.settings.gamepadselect import GamepadSelectWindow @@ -449,12 +370,6 @@ class ControlsSettingsWindow(ba.Window): sel_name = 'Keyboard2' elif sel == self._idevices_button: sel_name = 'iDevices' - elif sel == self._ps3_button: - sel_name = 'PS3' - elif sel == self._xbox_360_button: - sel_name = 'xbox360' - elif sel == self._wiimotes_button: - sel_name = 'Wiimotes' else: sel_name = 'Back' ba.app.ui.window_states[type(self)] = sel_name @@ -471,12 +386,6 @@ class ControlsSettingsWindow(ba.Window): sel = self._keyboard_2_button elif sel_name == 'iDevices': sel = self._idevices_button - elif sel_name == 'PS3': - sel = self._ps3_button - elif sel_name == 'xbox360': - sel = self._xbox_360_button - elif sel_name == 'Wiimotes': - sel = self._wiimotes_button elif sel_name == 'Back': sel = self._back_button else: diff --git a/dist/ba_data/python/bastd/ui/settings/nettesting.py b/dist/ba_data/python/bastd/ui/settings/nettesting.py index e8b645c..31dd809 100644 --- a/dist/ba_data/python/bastd/ui/settings/nettesting.py +++ b/dist/ba_data/python/bastd/ui/settings/nettesting.py @@ -4,11 +4,296 @@ from __future__ import annotations +import time +import copy +import weakref +from threading import Thread +from typing import TYPE_CHECKING + +import _ba import ba -from bastd.ui.settings import testing +from bastd.ui.settings.testing import TestingWindow + +if TYPE_CHECKING: + from typing import Callable, Any, Optional -class NetTestingWindow(testing.TestingWindow): +class NetTestingWindow(ba.Window): + """Window that runs a networking test suite to help diagnose issues.""" + + def __init__(self, transition: str = 'in_right'): + self._width = 820 + self._height = 500 + self._printed_lines: list[str] = [] + uiscale = ba.app.ui.uiscale + super().__init__(root_widget=ba.containerwidget( + size=(self._width, self._height), + scale=(1.56 if uiscale is ba.UIScale.SMALL else + 1.2 if uiscale is ba.UIScale.MEDIUM else 0.8), + stack_offset=(0.0, -7 if uiscale is ba.UIScale.SMALL else 0.0), + transition=transition)) + self._done_button = ba.buttonwidget(parent=self._root_widget, + position=(40, self._height - 77), + size=(120, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='doneText'), + on_activate_call=self._done) + + self._copy_button = ba.buttonwidget(parent=self._root_widget, + position=(self._width - 200, + self._height - 77), + size=(100, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(resource='copyText'), + on_activate_call=self._copy) + + self._settings_button = ba.buttonwidget( + parent=self._root_widget, + position=(self._width - 100, self._height - 77), + size=(60, 60), + scale=0.8, + autoselect=True, + label=ba.Lstr(value='...'), + on_activate_call=self._show_val_testing) + + twidth = self._width - 450 + ba.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height - 55), + size=(0, 0), + text=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'), + color=(0.8, 0.8, 0.8, 1.0), + h_align='center', + v_align='center', + maxwidth=twidth) + + self._scroll = ba.scrollwidget(parent=self._root_widget, + position=(50, 50), + size=(self._width - 100, + self._height - 140), + capture_arrows=True, + autoselect=True) + self._rows = ba.columnwidget(parent=self._scroll) + + ba.containerwidget(edit=self._root_widget, + cancel_button=self._done_button) + + # Now kick off the tests. + # Pass a weak-ref to this window so we don't keep it alive + # if we back out before it completes. Also set is as daemon + # so it doesn't keep the app running if the user is trying to quit. + Thread( + daemon=True, + target=ba.Call(_run_diagnostics, weakref.ref(self)), + ).start() + + def print(self, text: str, color: tuple[float, float, float]) -> None: + """Print text to our console thingie.""" + for line in text.splitlines(): + txt = ba.textwidget(parent=self._rows, + color=color, + text=line, + scale=0.75, + flatness=1.0, + shadow=0.0, + size=(0, 20)) + ba.containerwidget(edit=self._rows, visible_child=txt) + self._printed_lines.append(line) + + def _copy(self) -> None: + if not ba.clipboard_is_supported(): + ba.screenmessage('Clipboard not supported on this platform.', + color=(1, 0, 0)) + return + ba.clipboard_set_text('\n'.join(self._printed_lines)) + ba.screenmessage(f'{len(self._printed_lines)} lines copied.') + + def _show_val_testing(self) -> None: + ba.app.ui.set_main_menu_window(NetValTestingWindow().get_root_widget()) + ba.containerwidget(edit=self._root_widget, transition='out_left') + + def _done(self) -> None: + # pylint: disable=cyclic-import + from bastd.ui.settings.advanced import AdvancedSettingsWindow + ba.app.ui.set_main_menu_window( + AdvancedSettingsWindow(transition='in_left').get_root_widget()) + ba.containerwidget(edit=self._root_widget, transition='out_right') + + +def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None: + # pylint: disable=too-many-statements + + from efro.util import utc_now + + have_error = [False] + + # We're running in a background thread but UI stuff needs to run + # in the logic thread; give ourself a way to pass stuff to it. + def _print(text: str, color: tuple[float, float, float] = None) -> None: + + def _print_in_logic_thread() -> None: + win = weakwin() + if win is not None: + win.print(text, (1.0, 1.0, 1.0) if color is None else color) + + ba.pushcall(_print_in_logic_thread, from_other_thread=True) + + def _print_test_results(call: Callable[[], Any]) -> None: + """Run the provided call; return success/fail text & color.""" + starttime = time.monotonic() + try: + call() + duration = time.monotonic() - starttime + _print(f'Succeeded in {duration:.2f}s.', color=(0, 1, 0)) + except Exception: + import traceback + duration = time.monotonic() - starttime + _print(traceback.format_exc(), color=(1.0, 1.0, 0.3)) + _print(f'Failed in {duration:.2f}s.', color=(1, 0, 0)) + have_error[0] = True + + try: + _print(f'Running network diagnostics...\n' + f'ua: {_ba.app.user_agent_string}\n' + f'time: {utc_now()}.') + + if bool(False): + _print('\nRunning dummy success test...') + _print_test_results(_dummy_success) + + _print('\nRunning dummy fail test...') + _print_test_results(_dummy_fail) + + # V1 ping + baseaddr = _ba.get_master_server_address(source=0, version=1) + _print(f'\nContacting V1 master-server src0 ({baseaddr})...') + _print_test_results(lambda: _test_fetch(baseaddr)) + + # V1 alternate ping + baseaddr = _ba.get_master_server_address(source=1, version=1) + _print(f'\nContacting V1 master-server src1 ({baseaddr})...') + _print_test_results(lambda: _test_fetch(baseaddr)) + + _print(f'\nV1-test-log: {ba.app.net.v1_test_log}') + + for srcid, result in sorted(ba.app.net.v1_ctest_results.items()): + _print(f'\nV1 src{srcid} result: {result}') + + curv1addr = _ba.get_master_server_address(version=1) + _print(f'\nUsing V1 address: {curv1addr}') + + _print('\nRunning V1 transaction...') + _print_test_results(_test_v1_transaction) + + # V2 ping + baseaddr = _ba.get_master_server_address(version=2) + _print(f'\nContacting V2 master-server ({baseaddr})...') + _print_test_results(lambda: _test_fetch(baseaddr)) + + # Get V2 nearby region + with ba.app.net.region_pings_lock: + region_pings = copy.deepcopy(ba.app.net.region_pings) + nearest_region = (None if not region_pings else sorted( + region_pings.items(), key=lambda i: i[1])[0]) + + if nearest_region is not None: + nearstr = f'{nearest_region[0]}: {nearest_region[1]:.0f}ms' + else: + nearstr = '-' + _print(f'\nChecking nearest V2 region ping ({nearstr})...') + _print_test_results(lambda: _test_nearby_region_ping(nearest_region)) + + if have_error[0]: + _print('\nDiagnostics complete. Some diagnostics failed.', + color=(10, 0, 0)) + else: + _print('\nDiagnostics complete. Everything looks good!', + color=(0, 1, 0)) + except Exception: + import traceback + _print( + f'An unexpected error occurred during testing;' + f' please report this.\n' + f'{traceback.format_exc()}', + color=(1, 0, 0)) + + +def _dummy_success() -> None: + """Dummy success test.""" + time.sleep(1.2) + + +def _dummy_fail() -> None: + """Dummy fail test case.""" + raise RuntimeError('fail-test') + + +def _test_v1_transaction() -> None: + """Dummy fail test case.""" + if _ba.get_account_state() != 'signed_in': + raise RuntimeError('Not signed in.') + + starttime = time.monotonic() + + # Gets set to True on success or string on error. + results: list[Any] = [False] + + def _cb(cbresults: Any) -> None: + # Simply set results here; our other thread acts on them. + if not isinstance(cbresults, dict) or 'party_code' not in cbresults: + results[0] = 'Unexpected transaction response' + return + results[0] = True # Success! + + def _do_it() -> None: + # Fire off a transaction with a callback. + _ba.add_transaction( + { + 'type': 'PRIVATE_PARTY_QUERY', + 'expire_time': time.time() + 20, + }, + callback=_cb, + ) + _ba.run_transactions() + + ba.pushcall(_do_it, from_other_thread=True) + + while results[0] is False: + time.sleep(0.01) + if time.monotonic() - starttime > 10.0: + raise RuntimeError('timed out') + + # If we got left a string, its an error. + if isinstance(results[0], str): + raise RuntimeError(results[0]) + + +def _test_fetch(baseaddr: str) -> None: + # pylint: disable=consider-using-with + import urllib.request + response = urllib.request.urlopen(urllib.request.Request( + f'{baseaddr}/ping', None, {'User-Agent': _ba.app.user_agent_string}), + timeout=10.0) + if response.getcode() != 200: + raise RuntimeError( + f'Got unexpected response code {response.getcode()}.') + data = response.read() + if data != b'pong': + raise RuntimeError('Got unexpected response data.') + + +def _test_nearby_region_ping( + nearest_region: Optional[tuple[str, float]]) -> None: + """Try to ping nearest v2 region.""" + if nearest_region is None: + raise RuntimeError('No nearest region.') + if nearest_region[1] > 500: + raise RuntimeError('Ping too high.') + + +class NetValTestingWindow(TestingWindow): """Window to test network related settings.""" def __init__(self, transition: str = 'in_right'): @@ -35,6 +320,8 @@ class NetTestingWindow(testing.TestingWindow): 'increment': 1 }, ] - testing.TestingWindow.__init__( - self, ba.Lstr(resource='settingsWindowAdvanced.netTestingText'), - entries, transition) + super().__init__( + title=ba.Lstr(resource='settingsWindowAdvanced.netTestingText'), + entries=entries, + transition=transition, + back_call=lambda: NetTestingWindow(transition='in_left')) diff --git a/dist/ba_data/python/bastd/ui/settings/testing.py b/dist/ba_data/python/bastd/ui/settings/testing.py index 51f237e..54e8349 100644 --- a/dist/ba_data/python/bastd/ui/settings/testing.py +++ b/dist/ba_data/python/bastd/ui/settings/testing.py @@ -11,7 +11,7 @@ import _ba import ba if TYPE_CHECKING: - from typing import Any + from typing import Any, Callable, Optional class TestingWindow(ba.Window): @@ -20,11 +20,13 @@ class TestingWindow(ba.Window): def __init__(self, title: ba.Lstr, entries: list[dict[str, Any]], - transition: str = 'in_right'): + transition: str = 'in_right', + back_call: Optional[Callable[[], ba.Window]] = None): uiscale = ba.app.ui.uiscale self._width = 600 self._height = 324 if uiscale is ba.UIScale.SMALL else 400 self._entries = copy.deepcopy(entries) + self._back_call = back_call super().__init__(root_widget=ba.containerwidget( size=(self._width, self._height), transition=transition, @@ -176,8 +178,8 @@ class TestingWindow(ba.Window): def _do_back(self) -> None: # pylint: disable=cyclic-import - import bastd.ui.settings.advanced + from bastd.ui.settings.advanced import AdvancedSettingsWindow ba.containerwidget(edit=self._root_widget, transition='out_right') - ba.app.ui.set_main_menu_window( - bastd.ui.settings.advanced.AdvancedSettingsWindow( - transition='in_left').get_root_widget()) + backwin = (self._back_call() if self._back_call is not None else + AdvancedSettingsWindow(transition='in_left')) + ba.app.ui.set_main_menu_window(backwin.get_root_widget()) diff --git a/dist/ba_data/python/bastd/ui/settings/vrtesting.py b/dist/ba_data/python/bastd/ui/settings/vrtesting.py index a899caa..a6723f7 100644 --- a/dist/ba_data/python/bastd/ui/settings/vrtesting.py +++ b/dist/ba_data/python/bastd/ui/settings/vrtesting.py @@ -7,13 +7,13 @@ from __future__ import annotations from typing import TYPE_CHECKING import ba -from bastd.ui.settings import testing +from bastd.ui.settings.testing import TestingWindow if TYPE_CHECKING: from typing import Any -class VRTestingWindow(testing.TestingWindow): +class VRTestingWindow(TestingWindow): """Window for testing vr settings.""" def __init__(self, transition: str = 'in_right'): diff --git a/dist/ba_data/python/efro/call.py b/dist/ba_data/python/efro/call.py index 1ea3d98..9087a0f 100644 --- a/dist/ba_data/python/efro/call.py +++ b/dist/ba_data/python/efro/call.py @@ -266,8 +266,9 @@ if TYPE_CHECKING: def Call(*_args: Any, **_keywds: Any) -> Any: ... + # (Type-safe Partial) # A convenient wrapper around functools.partial which adds type-safety # (though it does not support keyword arguments). - partial = Call + tpartial = Call else: - partial = functools.partial + tpartial = functools.partial diff --git a/dist/ba_data/python/efro/dataclassio/__init__.py b/dist/ba_data/python/efro/dataclassio/__init__.py index 82e46ad..ac9e6c2 100644 --- a/dist/ba_data/python/efro/dataclassio/__init__.py +++ b/dist/ba_data/python/efro/dataclassio/__init__.py @@ -10,26 +10,45 @@ data formats in a nondestructive manner. from __future__ import annotations +from enum import Enum from typing import TYPE_CHECKING, TypeVar from efro.dataclassio._outputter import _Outputter from efro.dataclassio._inputter import _Inputter -from efro.dataclassio._base import Codec, IOAttrs -from efro.dataclassio._prep import ioprep, ioprepped, is_ioprepped_dataclass +from efro.dataclassio._base import Codec, IOAttrs, IOExtendedData +from efro.dataclassio._prep import (ioprep, ioprepped, will_ioprep, + is_ioprepped_dataclass) from efro.dataclassio._pathcapture import DataclassFieldLookup if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional __all__ = [ - 'Codec', 'IOAttrs', 'ioprep', 'ioprepped', 'is_ioprepped_dataclass', - 'DataclassFieldLookup', 'dataclass_to_dict', 'dataclass_to_json', - 'dataclass_from_dict', 'dataclass_from_json', 'dataclass_validate' + 'Codec', 'IOAttrs', 'IOExtendedData', 'ioprep', 'ioprepped', 'will_ioprep', + 'is_ioprepped_dataclass', 'DataclassFieldLookup', 'dataclass_to_dict', + 'dataclass_to_json', 'dataclass_from_dict', 'dataclass_from_json', + 'dataclass_validate' ] T = TypeVar('T') +class JsonStyle(Enum): + """Different style types for json.""" + + # Single line, no spaces, no sorting. Not deterministic. + # Use this for most storage purposes. + FAST = 'fast' + + # Single line, no spaces, sorted keys. Deterministic. + # Use this when output may be hashed or compared for equality. + SORTED = 'sorted' + + # Multiple lines, spaces, sorted keys. Deterministic. + # Use this for pretty human readable output. + PRETTY = 'pretty' + + def dataclass_to_dict(obj: Any, codec: Codec = Codec.JSON, coerce_to_float: bool = True) -> dict: @@ -45,7 +64,7 @@ def dataclass_to_dict(obj: Any, the ability to do a lossless round-trip with data). If coerce_to_float is True, integer values present on float typed fields - will be converted to floats in the dict output. If False, a TypeError + will be converted to float in the dict output. If False, a TypeError will be triggered. """ @@ -59,18 +78,23 @@ def dataclass_to_dict(obj: Any, def dataclass_to_json(obj: Any, coerce_to_float: bool = True, - pretty: bool = False) -> str: + pretty: bool = False, + sort_keys: Optional[bool] = None) -> str: """Utility function; return a json string from a dataclass instance. Basically json.dumps(dataclass_to_dict(...)). + By default, keys are sorted for pretty output and not otherwise, but + this can be overridden by supplying a value for the 'sort_keys' arg. """ import json jdict = dataclass_to_dict(obj=obj, coerce_to_float=coerce_to_float, codec=Codec.JSON) + if sort_keys is None: + sort_keys = pretty if pretty: - return json.dumps(jdict, indent=2, sort_keys=True) - return json.dumps(jdict, separators=(',', ':')) + return json.dumps(jdict, indent=2, sort_keys=sort_keys) + return json.dumps(jdict, separators=(',', ':'), sort_keys=sort_keys) def dataclass_from_dict(cls: type[T], @@ -94,10 +118,10 @@ def dataclass_from_dict(cls: type[T], (as this would break the ability to do a lossless round-trip with data). If coerce_to_float is True, int values passed for float typed fields - will be converted to float values. Otherwise a TypeError is raised. + will be converted to float values. Otherwise, a TypeError is raised. If allow_unknown_attrs is False, AttributeErrors will be raised for - attributes present in the dict but not on the data class. Otherwise they + attributes present in the dict but not on the data class. Otherwise, they will be preserved as part of the instance and included if it is exported back to a dict, unless discard_unknown_attrs is True, in which case they will simply be discarded. diff --git a/dist/ba_data/python/efro/dataclassio/_base.py b/dist/ba_data/python/efro/dataclassio/_base.py index 492a8d4..1353c36 100644 --- a/dist/ba_data/python/efro/dataclassio/_base.py +++ b/dist/ba_data/python/efro/dataclassio/_base.py @@ -12,15 +12,6 @@ from typing import TYPE_CHECKING, get_args # noinspection PyProtectedMember from typing import _AnnotatedAlias # type: ignore -_pytz_utc: Any - -# We don't *require* pytz but we want to support it for tzinfos if available. -try: - import pytz - _pytz_utc = pytz.utc -except ModuleNotFoundError: - _pytz_utc = None # pylint: disable=invalid-name - if TYPE_CHECKING: from typing import Any, Optional @@ -32,14 +23,6 @@ SIMPLE_TYPES = {int, bool, str, float, type(None)} EXTRA_ATTRS_ATTR = '_DCIOEXATTRS' -def _ensure_datetime_is_timezone_aware(value: datetime.datetime) -> None: - # We only support timezone-aware utc times. - if (value.tzinfo is not datetime.timezone.utc - and (_pytz_utc is None or value.tzinfo is not _pytz_utc)): - raise ValueError( - 'datetime values must have timezone set as timezone.utc') - - def _raise_type_error(fieldpath: str, valuetype: type, expected: tuple[type, ...]) -> None: """Raise an error when a field value's type does not match expected.""" @@ -67,6 +50,24 @@ class Codec(Enum): FIRESTORE = 'firestore' +class IOExtendedData: + """A class that data types can inherit from for extra functionality.""" + + def will_output(self) -> None: + """Called before data is sent to an outputter. + + Can be overridden to validate or filter data before + sending it on its way. + """ + + @classmethod + def will_input(cls, data: dict) -> None: + """Called on raw data before a class instance is created from it. + + Can be overridden to migrate old data formats to new, etc. + """ + + def _is_valid_for_codec(obj: Any, codec: Codec) -> bool: """Return whether a value consists solely of json-supported types. @@ -126,7 +127,7 @@ class IOAttrs: # Turning off store_default requires the field to have either # a default_factory or a default if not self.store_default: - default_factory: Any = field.default_factory # type: ignore + default_factory: Any = field.default_factory if (default_factory is dataclasses.MISSING and field.default is dataclasses.MISSING): raise TypeError(f'Field {field.name} of {cls} has' @@ -163,7 +164,7 @@ def _get_origin(anntype: Any) -> Any: def _parse_annotated(anntype: Any) -> tuple[Any, Optional[IOAttrs]]: """Parse Annotated() constructs, returning annotated type & IOAttrs.""" # If we get an Annotated[foo, bar, eep] we take - # foo as the actual type and we look for IOAttrs instances in + # foo as the actual type, and we look for IOAttrs instances in # bar/eep to affect our behavior. ioattrs: Optional[IOAttrs] = None if isinstance(anntype, _AnnotatedAlias): diff --git a/dist/ba_data/python/efro/dataclassio/_inputter.py b/dist/ba_data/python/efro/dataclassio/_inputter.py index 911fe4f..274e168 100644 --- a/dist/ba_data/python/efro/dataclassio/_inputter.py +++ b/dist/ba_data/python/efro/dataclassio/_inputter.py @@ -14,11 +14,11 @@ import typing import datetime from typing import TYPE_CHECKING, Generic, TypeVar -from efro.util import enum_by_value +from efro.util import enum_by_value, check_utc from efro.dataclassio._base import (Codec, _parse_annotated, EXTRA_ATTRS_ATTR, _is_valid_for_codec, _get_origin, SIMPLE_TYPES, _raise_type_error, - _ensure_datetime_is_timezone_aware) + IOExtendedData) from efro.dataclassio._prep import PrepSession if TYPE_CHECKING: @@ -48,6 +48,12 @@ class _Inputter(Generic[T]): def run(self, values: dict) -> T: """Do the thing.""" + + # For special extended data types, call their 'will_output' callback. + tcls = self._cls + if issubclass(tcls, IOExtendedData): + tcls.will_input(values) + out = self._dataclass_from_input(self._cls, '', values) assert isinstance(out, self._cls) return out @@ -70,14 +76,14 @@ class _Inputter(Generic[T]): return value if origin is typing.Union: - # Currently the only unions we support are None/Value + # Currently, the only unions we support are None/Value # (translated from Optional), which we verified on prep. # So let's treat this as a simple optional case. if value is None: return None childanntypes_l = [ c for c in typing.get_args(anntype) if c is not type(None) - ] + ] # noqa (pycodestyle complains about *is* with type) assert len(childanntypes_l) == 1 return self._value_from_input(cls, fieldpath, childanntypes_l[0], value, ioattrs) @@ -127,7 +133,7 @@ class _Inputter(Generic[T]): """Given input data, returns bytes.""" import base64 - # For firestore, bytes are passed as-is. Otherwise they're encoded + # For firestore, bytes are passed as-is. Otherwise, they're encoded # as base64. if self._codec is Codec.FIRESTORE: if not isinstance(value, bytes): @@ -159,6 +165,7 @@ class _Inputter(Generic[T]): prep = PrepSession(explicit=False).prep_dataclass(cls, recursion_level=0) + assert prep is not None extra_attrs = {} @@ -268,7 +275,7 @@ class _Inputter(Generic[T]): cls, fieldpath, valanntype, val, ioattrs) elif issubclass(keyanntype, Enum): - # In prep we verified that all these enums' values have + # In prep, we verified that all these enums' values have # the same type, so we can just look at the first to see if # this is a string enum or an int enum. enumvaltype = type(next(iter(keyanntype)).value) @@ -344,7 +351,7 @@ class _Inputter(Generic[T]): f'Invalid input value for "{fieldpath}" on' f' "{cls.__name__}";' f' expected a datetime, got a {type(value).__name__}') - _ensure_datetime_is_timezone_aware(value) + check_utc(value) return value assert self._codec is Codec.JSON @@ -355,9 +362,9 @@ class _Inputter(Generic[T]): f'Invalid input value for "{fieldpath}" on "{cls.__name__}";' f' expected a list, got a {type(value).__name__}') if len(value) != 7 or not all(isinstance(x, int) for x in value): - raise TypeError( + raise ValueError( f'Invalid input value for "{fieldpath}" on "{cls.__name__}";' - f' expected a list of 7 ints.') + f' expected a list of 7 ints, got {[type(v) for v in value]}.') out = datetime.datetime( # type: ignore *value, tzinfo=datetime.timezone.utc) if ioattrs is not None: @@ -380,9 +387,9 @@ class _Inputter(Generic[T]): assert childanntypes if len(value) != len(childanntypes): - raise TypeError(f'Invalid tuple input for "{fieldpath}";' - f' expected {len(childanntypes)} values,' - f' found {len(value)}.') + raise ValueError(f'Invalid tuple input for "{fieldpath}";' + f' expected {len(childanntypes)} values,' + f' found {len(value)}.') for i, childanntype in enumerate(childanntypes): childval = value[i] diff --git a/dist/ba_data/python/efro/dataclassio/_outputter.py b/dist/ba_data/python/efro/dataclassio/_outputter.py index 9dc193e..746223d 100644 --- a/dist/ba_data/python/efro/dataclassio/_outputter.py +++ b/dist/ba_data/python/efro/dataclassio/_outputter.py @@ -14,10 +14,11 @@ import typing import datetime from typing import TYPE_CHECKING +from efro.util import check_utc from efro.dataclassio._base import (Codec, _parse_annotated, EXTRA_ATTRS_ATTR, _is_valid_for_codec, _get_origin, SIMPLE_TYPES, _raise_type_error, - _ensure_datetime_is_timezone_aware) + IOExtendedData) from efro.dataclassio._prep import PrepSession if TYPE_CHECKING: @@ -37,6 +38,11 @@ class _Outputter: def run(self) -> Any: """Do the thing.""" + + # For special extended data types, call their 'will_output' callback. + if isinstance(self._obj, IOExtendedData): + self._obj.will_output() + return self._process_dataclass(type(self._obj), self._obj, '') def _process_dataclass(self, cls: type, obj: Any, fieldpath: str) -> Any: @@ -44,6 +50,7 @@ class _Outputter: # pylint: disable=too-many-branches prep = PrepSession(explicit=False).prep_dataclass(type(obj), recursion_level=0) + assert prep is not None fields = dataclasses.fields(obj) out: Optional[dict[str, Any]] = {} if self._create else None for field in fields: @@ -60,7 +67,7 @@ class _Outputter: # If we're not storing default values for this fella, # we can skip all output processing if we've got a default value. if ioattrs is not None and not ioattrs.store_default: - default_factory: Any = field.default_factory # type: ignore + default_factory: Any = field.default_factory if default_factory is not dataclasses.MISSING: if default_factory() == value: continue @@ -113,14 +120,14 @@ class _Outputter: return value if self._create else None if origin is typing.Union: - # Currently the only unions we support are None/Value + # Currently, the only unions we support are None/Value # (translated from Optional), which we verified on prep. # So let's treat this as a simple optional case. if value is None: return None childanntypes_l = [ c for c in typing.get_args(anntype) if c is not type(None) - ] + ] # noqa (pycodestyle complains about *is* with type) assert len(childanntypes_l) == 1 return self._process_value(cls, fieldpath, childanntypes_l[0], value, ioattrs) @@ -242,7 +249,7 @@ class _Outputter: if not isinstance(value, origin): raise TypeError(f'Expected a {origin} for {fieldpath};' f' found a {type(value)}.') - _ensure_datetime_is_timezone_aware(value) + check_utc(value) if ioattrs is not None: ioattrs.validate_datetime(value, fieldpath) if self._codec is Codec.FIRESTORE: diff --git a/dist/ba_data/python/efro/dataclassio/_pathcapture.py b/dist/ba_data/python/efro/dataclassio/_pathcapture.py index d325b6a..af7113f 100644 --- a/dist/ba_data/python/efro/dataclassio/_pathcapture.py +++ b/dist/ba_data/python/efro/dataclassio/_pathcapture.py @@ -36,6 +36,7 @@ class _PathCapture: prep = PrepSession(explicit=False).prep_dataclass(self._cls, recursion_level=0) + assert prep is not None try: anntype = prep.annotations[name] except KeyError as exc: @@ -75,7 +76,7 @@ class DataclassFieldLookup(Generic[T]): # We tell the type system that we are returning an instance # of our class, which allows it to perform type checking on # member lookups. In reality, however, we are providing a - # special object which captures path lookups so we can build + # special object which captures path lookups, so we can build # a string from them. if not TYPE_CHECKING: out = callback(_PathCapture(self.cls)) diff --git a/dist/ba_data/python/efro/dataclassio/_prep.py b/dist/ba_data/python/efro/dataclassio/_prep.py index 55cc814..57785b1 100644 --- a/dist/ba_data/python/efro/dataclassio/_prep.py +++ b/dist/ba_data/python/efro/dataclassio/_prep.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, TypeVar, get_type_hints from efro.dataclassio._base import _parse_annotated, _get_origin, SIMPLE_TYPES if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional T = TypeVar('T') @@ -27,11 +27,15 @@ T = TypeVar('T') # (basically for detecting recursive types) MAX_RECURSION = 10 -# Attr name for data we store on dataclass types as part of prep. +# Attr name for data we store on dataclass types that have been prepped. PREP_ATTR = '_DCIOPREP' +# We also store the prep-session while the prep is in progress. +# (necessary to support recursive types). +PREP_SESSION_ATTR = '_DCIOPREPSESSION' -def ioprep(cls: type) -> None: + +def ioprep(cls: type, globalns: dict = None) -> None: """Prep a dataclass type for use with this module's functionality. Prepping ensures that all types contained in a data class as well as @@ -45,10 +49,14 @@ def ioprep(cls: type) -> None: Prepping a dataclass involves evaluating its type annotations, which, as of PEP 563, are stored simply as strings. This evaluation is done - in the module namespace containing the class, so all referenced types - must be defined at that level. + with localns set to the class dict (so that types defined in the class + can be used) and globalns set to the containing module's class. + It is possible to override globalns for special cases such as when + prepping happens as part of an execed string instead of within a + module. """ - PrepSession(explicit=True).prep_dataclass(cls, recursion_level=0) + PrepSession(explicit=True, + globalns=globalns).prep_dataclass(cls, recursion_level=0) def ioprepped(cls: type[T]) -> type[T]: @@ -64,6 +72,23 @@ def ioprepped(cls: type[T]) -> type[T]: return cls +def will_ioprep(cls: type[T]) -> type[T]: + """Class decorator hinting that we will prep a class later. + + In some cases (such as recursive types) we cannot use the @ioprepped + decorator and must instead call ioprep() explicitly later. However, + some of our custom pylint checking behaves differently when the + @ioprepped decorator is present, in that case requiring type annotations + to be present and not simply forward declared under an "if TYPE_CHECKING" + block. (since they are used at runtime). + + The @will_ioprep decorator triggers the same pylint behavior + differences as @ioprepped (which are necessary for the later ioprep() call + to work correctly) but without actually running any prep itself. + """ + return cls + + def is_ioprepped_dataclass(obj: Any) -> bool: """Return whether the obj is an ioprepped dataclass type or instance.""" cls = obj if isinstance(obj, type) else type(obj) @@ -87,11 +112,19 @@ class PrepData: class PrepSession: """Context for a prep.""" - def __init__(self, explicit: bool): + def __init__(self, explicit: bool, globalns: Optional[dict] = None): self.explicit = explicit + self.globalns = globalns - def prep_dataclass(self, cls: type, recursion_level: int) -> PrepData: - """Run prep on a dataclass if necessary and return its prep data.""" + def prep_dataclass(self, cls: type, + recursion_level: int) -> Optional[PrepData]: + """Run prep on a dataclass if necessary and return its prep data. + + The only case where this will return None is for recursive types + if the type is already being prepped higher in the call order. + """ + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches # We should only need to do this once per dataclass. existing_data = getattr(cls, PREP_ATTR, None) @@ -99,8 +132,9 @@ class PrepSession: assert isinstance(existing_data, PrepData) return existing_data - # If we run into classes containing themselves, we may have - # to do something smarter to handle it. + # Sanity check. + # Note that we now support recursive types via the PREP_SESSION_ATTR, + # so we theoretically shouldn't run into this this. if recursion_level > MAX_RECURSION: raise RuntimeError('Max recursion exceeded.') @@ -108,6 +142,18 @@ class PrepSession: if not isinstance(cls, type) or not dataclasses.is_dataclass(cls): raise TypeError(f'Passed arg {cls} is not a dataclass type.') + # Add a pointer to the prep-session while doing the prep. + # This way we can ignore types that we're already in the process + # of prepping and can support recursive types. + existing_prep = getattr(cls, PREP_SESSION_ATTR, None) + if existing_prep is not None: + if existing_prep is self: + return None + # We shouldn't need to support failed preps + # or preps from multiple threads at once. + raise RuntimeError('Found existing in-progress prep.') + setattr(cls, PREP_SESSION_ATTR, self) + # Generate a warning on non-explicit preps; we prefer prep to # happen explicitly at runtime so errors can be detected early on. if not self.explicit: @@ -123,10 +169,10 @@ class PrepSession: # which allows us to pick up nested classes, etc. resolved_annotations = get_type_hints(cls, localns=vars(cls), + globalns=self.globalns, include_extras=True) # pylint: enable=unexpected-keyword-arg except Exception as exc: - print('GOT', cls.__dict__) raise TypeError( f'dataclassio prep for {cls} failed with error: {exc}.' f' Make sure all types used in annotations are defined' @@ -175,6 +221,10 @@ class PrepSession: annotations=resolved_annotations, storage_names_to_attr_names=storage_names_to_attr_names) setattr(cls, PREP_ATTR, prepdata) + + # Clear our prep-session tag. + assert getattr(cls, PREP_SESSION_ATTR, None) is self + delattr(cls, PREP_SESSION_ATTR) return prepdata def prep_type(self, cls: type, attrname: str, anntype: Any, @@ -303,7 +353,7 @@ class PrepSession: """Run prep on a Union type.""" typeargs = typing.get_args(anntype) if (len(typeargs) != 2 - or len([c for c in typeargs if c is type(None)]) != 1): + or len([c for c in typeargs if c is type(None)]) != 1): # noqa raise TypeError(f'Union {anntype} for attr \'{attrname}\' on' f' {cls.__name__} is not supported by dataclassio;' f' only 2 member Unions with one type being None' diff --git a/dist/ba_data/python/efro/error.py b/dist/ba_data/python/efro/error.py index 4627ffc..3bc1a54 100644 --- a/dist/ba_data/python/efro/error.py +++ b/dist/ba_data/python/efro/error.py @@ -84,6 +84,12 @@ def is_urllib_network_error(exc: BaseException) -> bool: exc, (urllib.error.URLError, ConnectionError, http.client.IncompleteRead, http.client.BadStatusLine, socket.timeout)): + # Special case: although an HTTPError is a subclass of URLError, + # we don't return True for it. It means we have successfully + # communicated with the server but what we are asking for is + # not there/etc. + if isinstance(exc, urllib.error.HTTPError): + return False return True if isinstance(exc, OSError): if exc.errno == 10051: # Windows unreachable network error. diff --git a/dist/ba_data/python/efro/message.py b/dist/ba_data/python/efro/message.py index 8d9bbfc..f6f48b7 100644 --- a/dist/ba_data/python/efro/message.py +++ b/dist/ba_data/python/efro/message.py @@ -766,7 +766,7 @@ class MessageReceiver: # Return type of None translates to EmptyResponse. responsetypes = tuple(EmptyResponse if r is type(None) else r - for r in responsetypes) + for r in responsetypes) # noqa # Make sure our protocol has this message type registered and our # return types exactly match. (Technically we could return a subset diff --git a/dist/ba_data/python/efro/terminal.py b/dist/ba_data/python/efro/terminal.py index cd06409..d0351d5 100644 --- a/dist/ba_data/python/efro/terminal.py +++ b/dist/ba_data/python/efro/terminal.py @@ -75,6 +75,10 @@ def _default_color_enabled() -> bool: if not sys.__stdout__.isatty(): return False + # Another common way to say the terminal can't do fancy stuff like color: + if os.environ.get('TERM') == 'dumb': + return False + # On windows, try to enable ANSI color mode. if platform.system() == 'Windows': return _windows_enable_color() diff --git a/dist/ba_data/python/efro/util.py b/dist/ba_data/python/efro/util.py index 285505f..14d8bc1 100644 --- a/dist/ba_data/python/efro/util.py +++ b/dist/ba_data/python/efro/util.py @@ -11,10 +11,19 @@ import functools from enum import Enum from typing import TYPE_CHECKING, cast, TypeVar, Generic +_pytz_utc: Any + +# We don't *require* pytz, but we want to support it for tzinfos if available. +try: + import pytz + _pytz_utc = pytz.utc +except ModuleNotFoundError: + _pytz_utc = None # pylint: disable=invalid-name + if TYPE_CHECKING: import asyncio from efro.call import Call as Call # 'as Call' so we re-export. - from typing import Any, Callable, Optional + from typing import Any, Callable, Optional, NoReturn T = TypeVar('T') TVAL = TypeVar('TVAL') @@ -62,6 +71,14 @@ def enum_by_value(cls: type[TENUM], value: Any) -> TENUM: (value, cls.__name__)) from None +def check_utc(value: datetime.datetime) -> None: + """Ensure a datetime value is timezone-aware utc.""" + if (value.tzinfo is not datetime.timezone.utc + and (_pytz_utc is None or value.tzinfo is not _pytz_utc)): + raise ValueError('datetime value does not have timezone set as' + ' datetime.timezone.utc') + + def utc_now() -> datetime.datetime: """Get offset-aware current utc time. @@ -240,7 +257,8 @@ class DispatchMethodWrapper(Generic[TARG, TRET]): pass @staticmethod - def register(func: Callable[[Any, Any], TRET]) -> Callable: + def register( + func: Callable[[Any, Any], TRET]) -> Callable[[Any, Any], TRET]: """Register a new dispatch handler for this dispatch-method.""" registry: dict[Any, Callable] @@ -312,12 +330,16 @@ class ValueDispatcher(Generic[TVAL, TRET]): return handler() return self._base_call(value) - def _add_handler(self, value: TVAL, call: Callable[[], TRET]) -> None: + def _add_handler(self, value: TVAL, + call: Callable[[], TRET]) -> Callable[[], TRET]: if value in self._handlers: raise RuntimeError(f'Duplicate handlers added for {value}') self._handlers[value] = call + return call - def register(self, value: TVAL) -> Callable[[Callable[[], TRET]], None]: + def register( + self, + value: TVAL) -> Callable[[Callable[[], TRET]], Callable[[], TRET]]: """Add a handler to the dispatcher.""" from functools import partial return partial(self._add_handler, value) @@ -343,13 +365,16 @@ class ValueDispatcher1Arg(Generic[TVAL, TARG, TRET]): return handler(arg) return self._base_call(value, arg) - def _add_handler(self, value: TVAL, call: Callable[[TARG], TRET]) -> None: + def _add_handler(self, value: TVAL, + call: Callable[[TARG], TRET]) -> Callable[[TARG], TRET]: if value in self._handlers: raise RuntimeError(f'Duplicate handlers added for {value}') self._handlers[value] = call + return call - def register(self, - value: TVAL) -> Callable[[Callable[[TARG], TRET]], None]: + def register( + self, value: TVAL + ) -> Callable[[Callable[[TARG], TRET]], Callable[[TARG], TRET]]: """Add a handler to the dispatcher.""" from functools import partial return partial(self._add_handler, value) @@ -363,8 +388,9 @@ if TYPE_CHECKING: def __call__(self, value: TVAL) -> TRET: ... - def register(self, - value: TVAL) -> Callable[[Callable[[TSELF], TRET]], None]: + def register( + self, value: TVAL + ) -> Callable[[Callable[[TSELF], TRET]], Callable[[TSELF], TRET]]: """Add a handler to the dispatcher.""" ... @@ -579,6 +605,8 @@ def human_readable_compact_id(num: int) -> str: 'o' is excluded due to similarity to '0'. 'z' is excluded due to similarity to '2'. + Therefore for n chars this can store values of 21^n. + When reading human input consisting of these IDs, it may be desirable to map the disallowed chars to their corresponding allowed ones ('o' -> '0', etc). @@ -599,8 +627,39 @@ def compact_id(num: int) -> str: friendly to humans due to using both capital and lowercase letters, both 'O' and '0', etc. + Therefore for n chars this can store values of 62^n. + Sort order for these ids is the same as the original numbers. """ return _compact_id( num, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz') + + +def assert_never(value: NoReturn) -> NoReturn: + """Trick for checking exhaustive handling of Enums, etc. + + See https://github.com/python/typing/issues/735 + """ + assert False, f'Unhandled value: {value} ({type(value).__name__})' + + +def unchanging_hostname() -> str: + """Return an unchanging name for the local device. + + Similar to the `hostname` call (or os.uname().nodename in Python) + except attempts to give a name that doesn't change depending on + network conditions. (A Mac will tend to go from Foo to Foo.local, + Foo.lan etc. throughout its various adventures) + """ + import os + import platform + import subprocess + + # On Mac, this should give the computer name assigned in System Prefs. + if platform.system() == 'Darwin': + return subprocess.run( + ['scutil', '--get', 'ComputerName'], + check=True, + capture_output=True).stdout.decode().strip().replace(' ', '-') + return os.uname().nodename diff --git a/dist/ba_root/mods/bcs_plugin.py b/dist/ba_root/mods/bcs_plugin.py index 0aa552d..a12f6a9 100644 --- a/dist/ba_root/mods/bcs_plugin.py +++ b/dist/ba_root/mods/bcs_plugin.py @@ -33,15 +33,19 @@ class BsDataThread(object): lboard=mystats.get_all_stats() leaderboard=lboard - entries = [(a['scores'], a['kills'], a['deaths'], a['games'], a['name'], a['aid']) for a in lboard.values()] - + try: + entries = [(a['scores'], a['kills'], a['deaths'], a['games'], a['name'], a['aid'],a['last_seen']) for a in lboard.values()] + except: + print("stats reset is required , please clear out stats.json records , or download fresh stats.json from github") + import _ba + _ba.quit() entries.sort(reverse=True) rank=0 for entry in entries: rank+=1 if rank >201: break - _t200[entry[5]]={"rank":rank,"scores":int(entry[0]),"games":int(entry[3]),"kills":int(entry[1]),"deaths":int(entry[2]),"name_html":entry[4],"last_seen":"entry[6]"} + _t200[entry[5]]={"rank":rank,"scores":int(entry[0]),"games":int(entry[3]),"kills":int(entry[1]),"deaths":int(entry[2]),"name_html":entry[4],"last_seen":entry[6]} top200=_t200 def refreshStats(self): diff --git a/dist/ba_root/mods/stats/stats.json b/dist/ba_root/mods/stats/stats.json index 230bcb5..5710e52 100644 --- a/dist/ba_root/mods/stats/stats.json +++ b/dist/ba_root/mods/stats/stats.json @@ -11,7 +11,8 @@ "games": 18, "kd": 0.0, "avg_score": 0.0, - "aid": "pb-IF4VAk4a" + "aid": "pb-IF4VAk4a", + "last_seen":"2022-03-10 12:46:46.070990" } } -} \ No newline at end of file +} diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index 127bd81..f62f0b0 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ