diff --git a/bombsquad_server b/bombsquad_server index c82f94d..2431dfc 100644 --- a/bombsquad_server +++ b/bombsquad_server @@ -1,8 +1,9 @@ -#!/usr/bin/env python3.13 +#!/usr/bin/env -S python3.13 -OB # Released under the MIT License. See LICENSE for details. # # pylint: disable=too-many-lines """BallisticaKit server manager.""" + from __future__ import annotations import os @@ -11,6 +12,7 @@ import time import json import signal import tomllib +import logging import subprocess import platform from pathlib import Path @@ -25,19 +27,39 @@ sys.path += [ str(Path(Path(__file__).parent, 'dist', 'ba_data', 'python-site-packages')), ] -from efro.terminal import Clr -from efro.error import CleanError -from efro.dataclassio import dataclass_from_dict, dataclass_validate from bacommon.servermanager import ServerConfig, StartServerModeCommand +from efro.dataclassio import dataclass_from_dict, dataclass_validate +from efro.error import CleanError +from efro.terminal import Clr if TYPE_CHECKING: from types import FrameType from bacommon.servermanager import ServerCommand -VERSION_STR = '1.3.2' +VERSION_STR = '1.3.5' # Version history: # +# 1.3.5 +# +# - Minor updates accounting for the fact that the game binary no longer +# bundles .pyc files but rather generates them itself in a dedicated +# directory. So we now run this wrapper with bytecode disabled (-B) +# to keep the source tree tidy; the wrapper isn't performance sensitive +# so this should have no impact on performance. +# +# 1.3.4 +# +# - Updated to use Python 3.13. +# +# 1.3.3 +# +# - Added log_levels dict in server config for setting levels on +# individual loggers within the server binary. Can be useful for +# debugging issues or just keeping better track of what the server is +# up to. Check the logging tab in the dev console in the graphical +# client to learn which loggers are available. +# # 1.3.2 # # - Updated to use Python 3.12. @@ -413,8 +435,7 @@ class ServerManagerApp: raise CleanError('Expected a config path as next arg.') path = sys.argv[i + 1] if not os.path.exists(path): - raise CleanError( - f"Supplied path does not exist: '{path}'.") + raise CleanError(f"Supplied path does not exist: '{path}'.") # We need an abs path because we may be in a different # cwd currently than we will be during the run. self._user_provided_config_path = os.path.abspath(path) @@ -702,6 +723,14 @@ class ServerManagerApp: # instead? os.environ['BA_SERVER_WRAPPER_MANAGED'] = '1' + # Set particular things that can *only* be passed as args and + # not config vals (because they need to be handled by the binary + # before spinning up Python or whatnot). + extra_args: list[str] = [] + + if self._config.dont_write_bytecode: + extra_args += ['--dont-write-bytecode'] + # Set an environment var to change the device name. Device name # is used while making connection with master server, # cloud-console recognize us with this name. @@ -721,7 +750,7 @@ class ServerManagerApp: # Launch! try: self._subprocess = subprocess.Popen( - [binary_name, '--config-dir', self._ba_root_path], + [binary_name, '--config-dir', self._ba_root_path] + extra_args, stdin=subprocess.PIPE, cwd='dist', ) @@ -799,26 +828,43 @@ class ServerManagerApp: bincfg = {} # Some of our config values translate directly into the - # ballisticakit config file; the rest we pass at runtime. bincfg['Port'] = int(os.environ.get('PORT', self._config.port)) bincfg['Auto Balance Teams'] = self._config.auto_balance_teams bincfg['Show Tutorial'] = self._config.show_tutorial + binkey = 'SceneV1 Host Protocol' if self._config.protocol_version is not None: - bincfg['SceneV1 Host Protocol'] = self._config.protocol_version - if self._config.team_names is not None: - bincfg['Custom Team Names'] = self._config.team_names - elif 'Custom Team Names' in bincfg: - del bincfg['Custom Team Names'] + bincfg[binkey] = self._config.protocol_version + elif binkey in bincfg: + del bincfg[binkey] + binkey = 'Custom Team Names' + if self._config.team_names is not None: + bincfg[binkey] = self._config.team_names + elif binkey in bincfg: + del bincfg[binkey] + + binkey = 'Custom Team Colors' if self._config.team_colors is not None: - bincfg['Custom Team Colors'] = self._config.team_colors - elif 'Custom Team Colors' in bincfg: - del bincfg['Custom Team Colors'] + bincfg[binkey] = self._config.team_colors + elif binkey in bincfg: + del bincfg[binkey] bincfg['Idle Exit Minutes'] = self._config.idle_exit_minutes + + binkey = 'Log Levels' + if self._config.log_levels is not None: + # Users supply us log level names like NOTSET; convert those + # to numeric vals which the engine expects. + bincfg[binkey] = { + key: logging.getLevelName(val) + for key, val in self._config.log_levels.items() + } + elif binkey in bincfg: + del bincfg[binkey] + with open(cfgpath, 'w', encoding='utf-8') as outfile: outfile.write(json.dumps(bincfg)) diff --git a/dist/ba_data/python/babase/__init__.py b/dist/ba_data/python/babase/__init__.py index b1e689d..43a4f55 100644 --- a/dist/ba_data/python/babase/__init__.py +++ b/dist/ba_data/python/babase/__init__.py @@ -7,6 +7,7 @@ directly. Instead one should use purpose-built packages such as :mod:`bascenev1` or :mod:`bauiv1` which themselves import various functionality from here and reexpose it in a more focused way. """ + # pylint: disable=redefined-builtin # ba_meta require api 9 @@ -15,14 +16,14 @@ functionality from here and reexpose it in a more focused way. # from other modules/packages. Code *within* this package should import # things from this package's submodules directly to reduce the chance of # dependency loops. The exception is TYPE_CHECKING blocks and -# annotations since those aren't evaluated at runtime. +# annotations - since those aren't evaluated at runtime, it is cleaner +# looking to use top level names directly. import _babase from _babase import ( add_clean_frame_callback, allows_ticket_sales, android_get_external_files_dir, - app_instance_uuid, appname, appnameupper, apptime, @@ -125,6 +126,7 @@ from _babase import ( ) from babase._accountv2 import AccountV2Handle, AccountV2Subsystem +from babase._analytics import AnalyticsSubsystem from babase._app import App, AppState from babase._appcomponent import AppComponentSubsystem from babase._appconfig import commit_app_config @@ -134,68 +136,71 @@ from babase._appsubsystem import AppSubsystem from babase._appmodeselector import AppModeSelector from babase._appconfig import AppConfig from babase._apputils import ( + AppHealthSubsystem, + get_remote_app_name, handle_leftover_v1_cloud_log_file, is_browser_likely_available, - get_remote_app_name, - AppHealthSubsystem, utc_now_cloud, ) from babase._cloud import CloudSubscription from babase._devconsole import ( DevConsoleButtonDef, + DevConsoleSubsystem, DevConsoleTab, DevConsoleTabEntry, - DevConsoleSubsystem, ) from babase._discord import DiscordSubsystem from babase._emptyappmode import EmptyAppMode from babase._error import ( + ActivityNotFoundError, + ActorNotFoundError, ContextError, + DelegateNotFoundError, + InputDeviceNotFoundError, + MapNotFoundError, + NodeNotFoundError, NotFoundError, PlayerNotFoundError, - SessionPlayerNotFoundError, - NodeNotFoundError, - ActorNotFoundError, - InputDeviceNotFoundError, - WidgetNotFoundError, - ActivityNotFoundError, - TeamNotFoundError, - MapNotFoundError, - SessionTeamNotFoundError, SessionNotFoundError, - DelegateNotFoundError, + SessionPlayerNotFoundError, + SessionTeamNotFoundError, + TeamNotFoundError, + WidgetNotFoundError, ) from babase._gc import GarbageCollectionSubsystem from babase._general import ( - DisplayTime, AppTime, - WeakCall, Call, - existing, + CallPartial, + CallStrict, + DisplayTime, Existable, - verify_object_death, - storagename, - getclass, + WeakCall, + WeakCallPartial, + WeakCallStrict, + existing, get_type_name, + getclass, + storagename, + verify_object_death, ) -from babase._language import Lstr, LanguageSubsystem +from babase._language import LanguageSubsystem, Lstr from babase._locale import LocaleSubsystem from babase._logging import ( - balog, accountlog, applog, + balog, lifecyclelog, netlog, uilog, ) from babase._login import LoginAdapter, LoginInfo - from babase._mgen.enums import ( - Permission, - SpecialChar, InputType, - UIScale, + Permission, QuitType, + SpecialChar, + UIScale, ) from babase._math import normalized_color, is_point_in_box, vec3validate from babase._meta import MetadataSubsystem @@ -216,6 +221,7 @@ __all__ = [ 'ActorNotFoundError', 'allows_ticket_sales', 'add_clean_frame_callback', + 'AnalyticsSubsystem', 'android_get_external_files_dir', 'app', 'App', @@ -227,7 +233,6 @@ __all__ = [ 'AppIntentExec', 'AppMode', 'AppState', - 'app_instance_uuid', 'applog', 'appname', 'appnameupper', @@ -242,6 +247,8 @@ __all__ = [ 'atexit', 'balog', 'Call', + 'CallPartial', + 'CallStrict', 'fullscreen_control_available', 'fullscreen_control_get', 'fullscreen_control_key_shortcut', @@ -391,6 +398,8 @@ __all__ = [ 'vec3validate', 'verify_object_death', 'WeakCall', + 'WeakCallPartial', + 'WeakCallStrict', 'WidgetNotFoundError', 'workspaces_in_use', 'WorkspaceSubsystem', diff --git a/dist/ba_data/python/babase/_accountv2.py b/dist/ba_data/python/babase/_accountv2.py index 0a24d40..657432a 100644 --- a/dist/ba_data/python/babase/_accountv2.py +++ b/dist/ba_data/python/babase/_accountv2.py @@ -4,9 +4,11 @@ from __future__ import annotations +import time import hashlib import logging from functools import partial +from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never from efro.error import CommunicationError @@ -19,6 +21,8 @@ import _babase if TYPE_CHECKING: from typing import Any, Callable + import bacommon.cloud + from babase._login import LoginAdapter, LoginInfo @@ -62,6 +66,9 @@ class AccountV2Subsystem: Callable[[AccountV2Handle | None], None] ] = CallbackSet() + # Request state per global-app-instance-id + self._auth_requests: dict[str, _AuthRequest] = {} + adapter: LoginAdapter if _babase.using_google_play_game_services(): adapter = LoginAdapterGPGS() @@ -104,6 +111,9 @@ class AccountV2Subsystem: """ assert _babase.in_logic_thread() + # Blow away any outstanding auth-requests. + self._auth_requests = {} + # Inform the base layer of new names/etc. if account is not None: _babase.set_account_sign_in_state(True, account.tag) @@ -201,6 +211,78 @@ class AccountV2Subsystem: self._initial_sign_in_completed = True _babase.app.on_initial_sign_in_complete() + def auth_request( + self, global_app_instance_id: str + ) -> None | tuple[bool, str]: + """Start/process an auth request.""" + import bacommon.cloud + + assert _babase.in_logic_thread() + plus = _babase.app.plus + assert plus is not None + + now = time.monotonic() + + # If there are any expired ones, do a prune pass. + if any(r.expire_time <= now for r in self._auth_requests.values()): + self._auth_requests = { + rid: r + for rid, r in self._auth_requests.items() + if r.expire_time > now + } + + auth_request = self._auth_requests.get(global_app_instance_id) + + # If we find no attempt in progress, kick one off. + if ( + auth_request is None + and plus.cloud.connected + and self.primary is not None + ): + # print('SENDING AUTH REQUEST') + auth_request = self._auth_requests[global_app_instance_id] = ( + _AuthRequest(expire_time=now + 10.0, error=None, token=None) + ) + with self.primary: + plus.cloud.send_message_cb( + bacommon.cloud.AuthRequestMessage(global_app_instance_id), + on_response=partial( + self._on_auth_request_response, auth_request + ), + ) + + # If we found results, return them. + if auth_request is None: + return None + if auth_request.error is not None: + assert auth_request.token is None + return (False, auth_request.error) + if auth_request.token is not None: + assert auth_request.error is None + return (True, auth_request.token) + # No error or token; its still in flight. + return None + + def _on_auth_request_response( + self, + auth_request: _AuthRequest, + response: bacommon.cloud.AuthRequestResponse | Exception, + ) -> None: + assert _babase.in_logic_thread() + + assert auth_request.error is None + assert auth_request.token is None + + if isinstance(response, Exception): + auth_request.error = 'An error has occurred.' + else: + # print('SETTING AUTH RESPONSE') + auth_request.error = response.error + auth_request.token = response.token + # Make sure this sticks around for long enough to complete + # the connection. + auth_request.expire_time = time.monotonic() + 10.0 + @staticmethod def _hashstr(val: str) -> str: md5 = hashlib.md5() @@ -501,3 +583,10 @@ class AccountV2Handle: This allows cloud messages to be sent on our behalf. """ + + +@dataclass +class _AuthRequest: + expire_time: float + error: str | None + token: str | None diff --git a/dist/ba_data/python/babase/_analytics.py b/dist/ba_data/python/babase/_analytics.py new file mode 100644 index 0000000..93aadac --- /dev/null +++ b/dist/ba_data/python/babase/_analytics.py @@ -0,0 +1,73 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Analytics functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bacommon.cloud +import _babase + +from babase._logging import balog + +if TYPE_CHECKING: + from bacommon.analytics import AnalyticsEvent + + +class AnalyticsSubsystem: + """Subsystem for wrangling analytics. + + Access the single shared instance of this class via the + :attr:`~babase.App.analytics` attr on the :class:`~babase.App` + class. + """ + + def __init__(self) -> None: + self.enabled: bool = True + + def submit_event(self, event: AnalyticsEvent) -> None: + """Submit an event. + + Should only be called from the logic thread. + """ + + if not _babase.in_logic_thread(): + balog.error( + 'submit_event() called outside logic thread.', stack_info=True + ) + return + + # No-op if analytics are disabled or we don't have plus. + if not self.enabled: + return + + plus = _babase.app.plus + if plus is None: + return + + # Currently just no-op if it seems we're not connected. Perhaps + # in the future we'd want to save these and submit later when we + # are. + if not plus.cloud.is_connected(): + return + + # Just kick off an immediate send in the bg with or without + # account info. + account = plus.accounts.primary + if account is None: + plus.cloud.send_message_cb( + bacommon.cloud.AnalyticsEventMessage(event), + on_response=self._on_analytics_message_response, + ) + else: + with account: + plus.cloud.send_message_cb( + bacommon.cloud.AnalyticsEventMessage(event), + on_response=self._on_analytics_message_response, + ) + + def _on_analytics_message_response( + self, response: Exception | None + ) -> None: + pass diff --git a/dist/ba_data/python/babase/_app.py b/dist/ba_data/python/babase/_app.py index 8a2673f..3807b88 100644 --- a/dist/ba_data/python/babase/_app.py +++ b/dist/ba_data/python/babase/_app.py @@ -2,10 +2,12 @@ # # pylint: disable=too-many-lines """Functionality related to the high level state of the app.""" + from __future__ import annotations import os import time +import asyncio import logging from enum import Enum from functools import partial @@ -28,12 +30,12 @@ from babase._appmodeselector import AppModeSelector from babase._appintent import AppIntentDefault, AppIntentExec from babase._stringedit import StringEditSubsystem from babase._devconsole import DevConsoleSubsystem +from babase._analytics import AnalyticsSubsystem from babase._appconfig import AppConfig from babase._logging import lifecyclelog, applog from babase._gc import GarbageCollectionSubsystem if TYPE_CHECKING: - import asyncio from typing import Any, Callable, Coroutine, Generator, Awaitable from concurrent.futures import Future @@ -152,6 +154,9 @@ class App: #: Subsystem for wrangling the dev-console UI. self.devconsole: DevConsoleSubsystem = DevConsoleSubsystem() + #: Subsystem for wrangling analytics. + self.analytics: AnalyticsSubsystem = AnalyticsSubsystem() + #: Incremented each time the app leaves the #: :attr:`~babase.AppState.SUSPENDED` state. This can be a simple #: way to determine if network data should be refreshed/etc. @@ -237,7 +242,6 @@ class App: loop. Hopefully this situation will be improved in the future with a unified event loop. """ - assert _babase.in_logic_thread() assert self._asyncio_loop is not None return self._asyncio_loop @@ -281,8 +285,9 @@ class App: def mode_selector(self, selector: babase.AppModeSelector) -> None: self._mode_selector = selector - def _on_task_done(self, task: asyncio.Task) -> None: + def _on_task_done(self, task: Any) -> None: # Report any errors that occurred. + assert isinstance(task, asyncio.Task) try: exc = task.exception() if exc is not None: @@ -1028,7 +1033,6 @@ class App: ) async def _shutdown(self) -> None: - import asyncio _babase.lock_all_input() try: @@ -1054,13 +1058,16 @@ class App: self, coro: Coroutine[None, None, None] ) -> None: """Run a shutdown task; report errors and abort if taking too long.""" - import asyncio task = asyncio.create_task(coro) try: await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS) + except TimeoutError: + # Log simple error message if it times out. + logging.error('Timed out waiting for shutdown task %s.', coro) except Exception: - logging.exception('Error in shutdown task (%s).', coro) + # Go with full ugly stack trace for anything unexpected. + logging.exception('Error in shutdown task %s.', coro) def _on_suspend(self) -> None: """Called when the app goes to a suspended state.""" @@ -1136,7 +1143,6 @@ class App: ) async def _wait_for_shutdown_suppressions(self) -> None: - import asyncio # Spin and wait for anything blocking shutdown to complete. starttime = _babase.apptime() @@ -1153,7 +1159,6 @@ class App: ) async def _fade_and_shutdown_graphics(self) -> None: - import asyncio # Kick off a short fade and give it time to complete. lifecyclelog.info('fade-and-shutdown-graphics begin') @@ -1189,7 +1194,6 @@ class App: lifecyclelog.info('fade-and-shutdown-graphics end') async def _fade_and_shutdown_audio(self) -> None: - import asyncio # Tell the audio system to go down and give it a bit of # time to do so gracefully. diff --git a/dist/ba_data/python/babase/_appcomponent.py b/dist/ba_data/python/babase/_appcomponent.py index e02ea02..1d92ba0 100644 --- a/dist/ba_data/python/babase/_appcomponent.py +++ b/dist/ba_data/python/babase/_appcomponent.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides the AppComponent class.""" + from __future__ import annotations from typing import TYPE_CHECKING, cast diff --git a/dist/ba_data/python/babase/_appconfig.py b/dist/ba_data/python/babase/_appconfig.py index e331071..89716b4 100644 --- a/dist/ba_data/python/babase/_appconfig.py +++ b/dist/ba_data/python/babase/_appconfig.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides the AppConfig class.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -10,7 +11,7 @@ import _babase if TYPE_CHECKING: from typing import Any -_g_pending_apply = False # pylint: disable=invalid-name +_g_pending_apply = False class AppConfig(dict): diff --git a/dist/ba_data/python/babase/_appintent.py b/dist/ba_data/python/babase/_appintent.py index d6d80e9..3b09079 100644 --- a/dist/ba_data/python/babase/_appintent.py +++ b/dist/ba_data/python/babase/_appintent.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides AppIntent functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/babase/_appmode.py b/dist/ba_data/python/babase/_appmode.py index 48f19f9..ffb27ae 100644 --- a/dist/ba_data/python/babase/_appmode.py +++ b/dist/ba_data/python/babase/_appmode.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides AppMode functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/babase/_appmodeselector.py b/dist/ba_data/python/babase/_appmodeselector.py index bd4da08..298fc9b 100644 --- a/dist/ba_data/python/babase/_appmodeselector.py +++ b/dist/ba_data/python/babase/_appmodeselector.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Contains AppModeSelector base class.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/babase/_appsubsystem.py b/dist/ba_data/python/babase/_appsubsystem.py index 11efcf4..6af6e86 100644 --- a/dist/ba_data/python/babase/_appsubsystem.py +++ b/dist/ba_data/python/babase/_appsubsystem.py @@ -1,11 +1,11 @@ # Released under the MIT License. See LICENSE for details. # """Provides the AppSubsystem base class.""" + from __future__ import annotations from typing import TYPE_CHECKING - if TYPE_CHECKING: from babase import UIScale diff --git a/dist/ba_data/python/babase/_apputils.py b/dist/ba_data/python/babase/_apputils.py index 7915970..9bf7326 100644 --- a/dist/ba_data/python/babase/_apputils.py +++ b/dist/ba_data/python/babase/_apputils.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Utility functionality related to the overall operation of the app.""" + from __future__ import annotations import os diff --git a/dist/ba_data/python/babase/_asyncio.py b/dist/ba_data/python/babase/_asyncio.py index 3d49012..89f9ae7 100644 --- a/dist/ba_data/python/babase/_asyncio.py +++ b/dist/ba_data/python/babase/_asyncio.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: import babase # Our timer and event loop for the ballistica logic thread. -_asyncio_timer: babase.AppTimer | None = None -_asyncio_event_loop: asyncio.AbstractEventLoop | None = None +_g_asyncio_timer: babase.AppTimer | None = None +_g_asyncio_event_loop: asyncio.AbstractEventLoop | None = None DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1' @@ -46,12 +46,12 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: except RuntimeError: pass - global _asyncio_event_loop - _asyncio_event_loop = asyncio.new_event_loop() - _asyncio_event_loop.set_default_executor(babase.app.threadpool) + global _g_asyncio_event_loop + _g_asyncio_event_loop = asyncio.new_event_loop() + _g_asyncio_event_loop.set_default_executor(babase.app.threadpool) # Try to avoid reference loops from exceptions. - _asyncio_event_loop.set_exception_handler(_exception_handler) + _g_asyncio_event_loop.set_exception_handler(_exception_handler) # Ideally we should integrate asyncio into our C++ Thread class's # low level event loop so that asyncio timers/sockets/etc. could @@ -62,10 +62,10 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: # See https://stackoverflow.com/questions/29782377/ # is-it-possible-to-run-only-a-single-step-of-the-asyncio-event-loop def run_cycle() -> None: - assert _asyncio_event_loop is not None - _asyncio_event_loop.call_soon(_asyncio_event_loop.stop) + assert _g_asyncio_event_loop is not None + _g_asyncio_event_loop.call_soon(_g_asyncio_event_loop.stop) starttime = time.monotonic() if DEBUG_TIMING else 0 - _asyncio_event_loop.run_forever() + _g_asyncio_event_loop.run_forever() endtime = time.monotonic() if DEBUG_TIMING else 0 # Let's aim to have nothing take longer than 1/120 of a second. @@ -79,21 +79,21 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: warn_time, ) - global _asyncio_timer - _asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True) + global _g_asyncio_timer + _g_asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True) 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 + assert _g_asyncio_event_loop is not None + assert asyncio.get_running_loop() is _g_asyncio_event_loop await asyncio.sleep(2.0) print('TEST AIO TASK ENDING') - _testtask = _asyncio_event_loop.create_task(aio_test()) + _testtask = _g_asyncio_event_loop.create_task(aio_test()) - return _asyncio_event_loop + return _g_asyncio_event_loop def _exception_handler( diff --git a/dist/ba_data/python/babase/_cloud.py b/dist/ba_data/python/babase/_cloud.py index 9ea0662..43cbd2d 100644 --- a/dist/ba_data/python/babase/_cloud.py +++ b/dist/ba_data/python/babase/_cloud.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Cloud related functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/babase/_devconsole.py b/dist/ba_data/python/babase/_devconsole.py index 24c4d2f..ba90111 100644 --- a/dist/ba_data/python/babase/_devconsole.py +++ b/dist/ba_data/python/babase/_devconsole.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Dev-Console functionality.""" + from __future__ import annotations import os diff --git a/dist/ba_data/python/babase/_devconsoletabs.py b/dist/ba_data/python/babase/_devconsoletabs.py index 18221f1..e535888 100644 --- a/dist/ba_data/python/babase/_devconsoletabs.py +++ b/dist/ba_data/python/babase/_devconsoletabs.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Predefined tabs for the dev console.""" + from __future__ import annotations import math diff --git a/dist/ba_data/python/babase/_discord.py b/dist/ba_data/python/babase/_discord.py index 27015ac..3c5aa39 100644 --- a/dist/ba_data/python/babase/_discord.py +++ b/dist/ba_data/python/babase/_discord.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. """Functionality related to discord sdk integration""" + from __future__ import annotations from typing import TYPE_CHECKING, override diff --git a/dist/ba_data/python/babase/_emptyappmode.py b/dist/ba_data/python/babase/_emptyappmode.py index 2c83a14..9add278 100644 --- a/dist/ba_data/python/babase/_emptyappmode.py +++ b/dist/ba_data/python/babase/_emptyappmode.py @@ -1,12 +1,11 @@ # Released under the MIT License. See LICENSE for details. # """Provides AppMode functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING, override -# from bacommon.app import AppExperience - import _babase from babase._appmode import AppMode from babase._appintent import AppIntentExec, AppIntentDefault diff --git a/dist/ba_data/python/babase/_env.py b/dist/ba_data/python/babase/_env.py index 4eb1660..2d1afe4 100644 --- a/dist/ba_data/python/babase/_env.py +++ b/dist/ba_data/python/babase/_env.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Environment related functionality.""" + from __future__ import annotations import os @@ -158,26 +159,6 @@ def on_main_thread_start_app() -> None: # situations. __main__.__builtins__.help = _CustomHelper() - # UPDATE: As of May 2025 I'm no longer seeing the below issue, so - # disabling this workaround for now and will remove it soon if no - # issues arise. - - # On Windows I'm seeing the following error creating asyncio loops - # in background threads with the default proactor setup: - - # ValueError: set_wakeup_fd only works in main thread of the main - # interpreter. - - # So let's explicitly request selector loops. Interestingly this - # error only started showing up once I moved Python init to the main - # thread; previously the various asyncio bg thread loops were - # working fine (maybe something caused them to default to selector - # in that case?.. - if sys.platform == 'win32' and bool(False): - import asyncio - - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - # Kick off networking bootstrapping. We do this here instead of in # our app net-subsystem so that it can proceed in parallel with the # rest of our bootstrapping (as networking stuff is often an overall @@ -647,5 +628,5 @@ class _CustomHelper: 'Interactive help is not available in this environment.\n' 'Type help(object) for help about object.' ) - return None - return pydoc.help(*args, **kwds) + return + pydoc.help(*args, **kwds) diff --git a/dist/ba_data/python/babase/_gc.py b/dist/ba_data/python/babase/_gc.py index 0729add..4d5672d 100644 --- a/dist/ba_data/python/babase/_gc.py +++ b/dist/ba_data/python/babase/_gc.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Utility functionality related to the overall operation of the app.""" + from __future__ import annotations import gc diff --git a/dist/ba_data/python/babase/_general.py b/dist/ba_data/python/babase/_general.py index d8982e2..18ebd2e 100644 --- a/dist/ba_data/python/babase/_general.py +++ b/dist/ba_data/python/babase/_general.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Utility snippets applying to generic Python code.""" + from __future__ import annotations import sys @@ -9,6 +10,7 @@ import weakref import random import logging import inspect +import warnings from typing import TYPE_CHECKING, TypeVar, Protocol, NewType, override from efro.terminal import Clr @@ -17,7 +19,7 @@ import _babase if TYPE_CHECKING: import functools - from typing import Any + from typing import Any, Callable # Declare distinct types for different time measurements we use so the @@ -93,160 +95,351 @@ def get_type_name(cls: type) -> str: return f'{cls.__module__}.{cls.__qualname__}' -class _WeakCall: - """Wrap a callable and arguments into a single callable object. +# Note: Something here is wonky with pylint, possibly related to our +# custom pylint plugin. Disabling all checks seems to fix it. +# pylint: disable=all +if TYPE_CHECKING: + # For type-checking, we point WeakCall and Call at + # functools.partial. This gives decent type-checking considering the + # open-ended nature of these calls (args being supplied at create + # time and/or at call time). Just remember that we're slightly lying + # to the type-checker here. + WeakCallPartial = functools.partial + CallPartial = functools.partial + WeakCall = functools.partial + Call = functools.partial +else: - When passed a bound method as the callable, the instance portion of - it is weak-referenced, meaning the underlying instance is free to - die if all other references to it go away. Should this occur, - calling the weak-call is simply a no-op. + class WeakCallPartial: + """Wrap a callable and arguments into a single callable object. - Think of this as a handy way to tell an object to do something at - some point in the future if it happens to still exist. + When passed a bound method as the callable, the instance portion of + it is weak-referenced, meaning the underlying instance is free to + die if all other references to it go away. Should this occur, + calling the weak-call is simply a no-op. - **EXAMPLE A:** This code will create a ``FooClass`` instance and - call its ``bar()`` method 5 seconds later; it will be kept alive - even though we overwrite its variable with None because the bound - method we pass as a timer callback (``foo.bar``) strong-references - it:: + Think of this as a handy way to tell an object to do something at + some point in the future if it happens to still exist. - foo = FooClass() - babase.apptimer(5.0, foo.bar) - foo = None + **EXAMPLE A:** This code will create a ``FooClass`` instance and + call its ``bar()`` method 5 seconds later; it will be kept alive + even though we overwrite its variable with None because the bound + method we pass as a timer callback (``foo.bar``) strong-references + it:: - **EXAMPLE B:** This code will *not* keep our object alive; it will - die when we overwrite it with ``None`` and the timer will be a no-op - when it fires:: + foo = FooClass() + babase.apptimer(5.0, foo.bar) + foo = None - foo = FooClass() - babase.apptimer(5.0, ba.WeakCall(foo.bar)) - foo = None + **EXAMPLE B:** This code will *not* keep our object alive; it will + die when we overwrite it with ``None`` and the timer will be a no-op + when it fires:: - **EXAMPLE C:** Wrap a method call with some positional and keyword - args:: + foo = FooClass() + babase.apptimer(5.0, ba.WeakCall(foo.bar)) + foo = None - myweakcall = babase.WeakCall(self.dostuff, argval1, - namedarg=argval2) + **EXAMPLE C:** Wrap a method call with some positional and keyword + args:: - # Now we have a single callable to run that whole mess. - # The same as calling myobj.dostuff(argval1, namedarg=argval2) - # (provided my_obj still exists; this will do nothing otherwise). - myweakcall() + myweakcall = babase.WeakCall(self.dostuff, argval1, + namedarg=argval2) - Note: additional args and keywords you provide to the weak-call - constructor are stored as regular strong-references; you'll need to - wrap them in weakrefs manually if desired. + # Now we have a single callable to run that whole mess. + # The same as calling myobj.dostuff(argval1, namedarg=argval2) + # (provided my_obj still exists; this will do nothing otherwise). + myweakcall() + + Note: additional args and keywords you provide to the weak-call + constructor are stored as regular strong-references; you'll need to + wrap them in weakrefs manually if desired. + """ + + # Optimize performance a bit; we shouldn't need to be super dynamic. + __slots__ = ['_call', '_args', '_keywds'] + + _did_invalid_call_warning = False + + def __init__(self, call: Any, /, *args: Any, **keywds: Any) -> None: + # Note: keeping _call, _args, _keywds private in this case + # since we sub functools.partial for ourself in + # type-checking so they will be unrecognized anyway. Use + # non-partial versions if you want to access those. + if hasattr(call, '__func__'): + self._call = WeakMethod(call) + else: + app = _babase.app + if not self._did_invalid_call_warning: + logging.warning( + 'Warning: callable passed to WeakCall() is not' + ' weak-referencable (%s); use regular Call() instead' + ' to avoid this warning.', + args[0], + stack_info=True, + ) + type(self)._did_invalid_call_warning = True + self._call = call + self._args = args + self._keywds = keywds + + def __call__(self, *args_extra: Any, **keywds_extra: Any) -> Any: + # Fast path: no extra args or kwargs. + if not args_extra and not keywds_extra: + return self._call(*self._args, **self._keywds) + + # Slightly slower path: handle extra args. + if not keywds_extra: + # Only extra positional args; skip dict merge. + return self._call(*(self._args + args_extra), **self._keywds) + + # Handle kw overrides (call-time kwargs overriding stored). + merged = {**self._keywds, **keywds_extra} + return self._call(*(self._args + args_extra), **merged) + + @override + def __repr__(self) -> str: + return ( + f'' + ) + + class CallPartial: + """Wraps a callable and args into a single callable object. + + The callable is strong-referenced so it won't die until this + object does. + + Note that a bound method (ex: ``myobj.dosomething``) contains a + reference to ``self`` (``myobj`` in that case), so you will be + keeping that object alive too. Use babase.WeakCall if you want + to pass a method to a callback without keeping its object alive. + + Example: Wrap a method call with 1 positional and 1 keyword arg:: + + mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2) + + # Now we have a single callable to run that whole mess. + # ..the same as calling myobj.dostuff(argval, namedarg=argval2) + mycall() + """ + + # Optimize performance a bit; we shouldn't need to be super dynamic. + __slots__ = ['_call', '_args', '_keywds'] + + def __init__(self, call: Any, /, *args: Any, **keywds: Any): + # Note: keeping _call, _args, _keywds private in this case + # since we sub functools.partial for ourself in + # type-checking so they will be unrecognized anyway. Use + # non-partial versions if you want to access those. + self._call = call + self._args = args + self._keywds = keywds + + def __call__(self, *args_extra: Any, **keywds_extra: Any) -> Any: + # Fast path: no extra args or kwargs. + if not args_extra and not keywds_extra: + return self._call(*self._args, **self._keywds) + + # Slightly slower path: handle extra args. + if not keywds_extra: + # Only extra positional args; skip dict merge. + return self._call(*(self._args + args_extra), **self._keywds) + + # Handle kw overrides (call-time kwargs overriding stored). + merged = {**self._keywds, **keywds_extra} + return self._call(*(self._args + args_extra), **merged) + + @override + def __repr__(self) -> str: + return ( + f'' + ) + + class WeakCall: + """Currently alias of :meth:`WeakCallPartial`.""" + + # Optimize performance a bit; we shouldn't need to be super dynamic. + __slots__ = ['_call', '_args', '_keywds'] + + _did_invalid_call_warning = False + + def __init__(self, call: Any, /, *args: Any, **keywds: Any) -> None: + warnings.warn( + 'WeakCall should be replaced with either WeakCallPartial' + ' (if passing extra args at call time) or WeakCallStrict' + ' (it not). Once API 9 support ends, WeakCall can again be' + ' used, but it will behave like WeakCallStrict instead of' + ' WeakCallPartial.', + DeprecationWarning, + stacklevel=2, + ) + # Note: keeping _call, _args, _keywds private in this case + # since we sub functools.partial for ourself in + # type-checking so they will be unrecognized anyway. Use + # non-partial versions if you want to access those. + if hasattr(call, '__func__'): + self._call = WeakMethod(call) + else: + app = _babase.app + if not self._did_invalid_call_warning: + logging.warning( + 'Warning: callable passed to WeakCall() is not' + ' weak-referencable (%r); use regular Call() instead' + ' to avoid this warning.', + args[0], + stack_info=True, + ) + type(self)._did_invalid_call_warning = True + self._call = call + self._args = args + self._keywds = keywds + + def __call__(self, *args_extra: Any, **keywds_extra: Any) -> Any: + # Fast path: no extra args or kwargs. + if not args_extra and not keywds_extra: + return self._call(*self._args, **self._keywds) + + # Slightly slower path: handle extra args. + if not keywds_extra: + # Only extra positional args; skip dict merge. + return self._call(*(self._args + args_extra), **self._keywds) + + # Handle kw overrides (call-time kwargs overriding stored). + merged = {**self._keywds, **keywds_extra} + return self._call(*(self._args + args_extra), **merged) + + @override + def __repr__(self) -> str: + return ( + f'' + ) + + class Call: + """Currently alias of :meth:`CallPartial`.""" + + # Optimize performance a bit; we shouldn't need to be super dynamic. + __slots__ = ['_call', '_args', '_keywds'] + + def __init__(self, call: Any, /, *args: Any, **keywds: Any): + warnings.warn( + 'Call should be replaced with either CallPartial' + ' (if passing extra args at call time) or CallStrict' + ' (it not). Once API 9 support ends, Call can again be' + ' used, but it will behave like CallStrict instead' + ' of CallPartial.', + DeprecationWarning, + stacklevel=2, + ) + # Note: keeping _call, _args, _keywds private in this case + # since we sub functools.partial for ourself in + # type-checking so they will be unrecognized anyway. Use + # non-partial versions if you want to access those. + self._call = call + self._args = args + self._keywds = keywds + + def __call__(self, *args_extra: Any, **keywds_extra: Any) -> Any: + # Fast path: no extra args or kwargs. + if not args_extra and not keywds_extra: + return self._call(*self._args, **self._keywds) + + # Slightly slower path: handle extra args. + if not keywds_extra: + # Only extra positional args; skip dict merge. + return self._call(*(self._args + args_extra), **self._keywds) + + # Handle kw overrides (call-time kwargs overriding stored). + merged = {**self._keywds, **keywds_extra} + return self._call(*(self._args + args_extra), **merged) + + @override + def __repr__(self) -> str: + return ( + f'' + ) + + +# pylint: enable=all + + +class CallStrict[**P, T]: + """Like :meth:`CallPartial()` but disallows extra args at call time. + + This allows more complete type checking to occur, so this is + recommended if you do not need extra args at call time. """ - # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] + __slots__ = ('call', 'args', 'kwargs') + + def __init__( + self, call: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs + ) -> None: + # Note: we allow access to these here since we don't use any + # tricks like pointing at functools.partial for type checking or + # whatnot that would break this. + self.call = call + self.args = args + self.kwargs = kwargs + + def __call__(self) -> T: + return self.call(*self.args, **self.kwargs) + + @override + def __repr__(self) -> str: + return ( + f'' + ) + + +class WeakCallStrict[**P, T]: + """Like :meth:`WeakCallPartial()` but disallows extra args at call time. + + This allows more complete type checking to occur, so this is + recommended if you do not need extra args at call time. + """ + + __slots__ = ('call', 'args', 'kwargs') _did_invalid_call_warning = False - def __init__(self, *args: Any, **keywds: Any) -> None: - if hasattr(args[0], '__func__'): - self._call = WeakMethod(args[0]) + def __init__( + self, call: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs + ) -> None: + # Note: we allow access to these here since we don't use any + # tricks like pointing at functools.partial for type checking or + # whatnot that would break this. + if hasattr(call, '__func__'): + self.call: Any = WeakMethod(call) # type: ignore else: app = _babase.app if not self._did_invalid_call_warning: logging.warning( - 'Warning: callable passed to babase.WeakCall() is not' - ' weak-referencable (%s); use functools.partial instead' + 'Warning: callable passed to WeakCallStrict() is not' + ' weak-referencable (%r); use regular CallStrict() instead' ' to avoid this warning.', args[0], stack_info=True, ) type(self)._did_invalid_call_warning = True - self._call = args[0] - self._args = args[1:] - self._keywds = keywds + self.call = call + self.args = args + self.kwargs = kwargs - def __call__(self, *args_extra: Any) -> Any: - return self._call(*self._args + args_extra, **self._keywds) + def __call__(self) -> T: + return self.call(*self.args, **self.kwargs) # type: ignore @override - def __str__(self) -> str: + def __repr__(self) -> str: return ( - '' + f'' ) -class _Call: - """Wraps a callable and arguments into a single callable object. - - The callable is strong-referenced so it won't die until this - object does. - - Note that a bound method (ex: ``myobj.dosomething``) contains a reference - to ``self`` (``myobj`` in that case), so you will be keeping that object - alive too. Use babase.WeakCall if you want to pass a method to a callback - without keeping its object alive. - - Example: Wrap a method call with 1 positional and 1 keyword arg:: - - mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2) - - # Now we have a single callable to run that whole mess. - # ..the same as calling myobj.dostuff(argval, namedarg=argval2) - mycall() - """ - - # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] - - def __init__(self, *args: Any, **keywds: Any): - self._call = args[0] - self._args = args[1:] - self._keywds = keywds - - def __call__(self, *args_extra: Any) -> Any: - return self._call(*self._args + args_extra, **self._keywds) - - @override - def __str__(self) -> str: - return ( - '' - ) - - -if TYPE_CHECKING: - # For type-checking, point at functools.partial which gives us full - # type checking on both positional and keyword arguments (as of mypy - # 1.11). - - # FIXME: Actually, currently (as of Dec 2024) mypy doesn't fully - # type check partial. The partial() call itself is checked, but the - # resulting callable seems to be essentially untyped. We should - # probably revise this stuff so that Call and WeakCall are for 100% - # complete calls so we can fully type check them using ParamSpecs or - # whatnot. We could then write a weak_partial() call if we actually - # need that particular combination of functionality. - - # Note: Something here is wonky with pylint, possibly related to our - # custom pylint plugin. Disabling all checks seems to fix it. - # pylint: disable=all - - WeakCall = functools.partial - Call = functools.partial -else: - WeakCall = _WeakCall - WeakCall.__name__ = 'WeakCall' - Call = _Call - Call.__name__ = 'Call' - - class WeakMethod: """A weak-referenced bound method. @@ -255,22 +448,22 @@ class WeakMethod: """ # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_func', '_obj'] + __slots__ = ['func', 'obj'] def __init__(self, call: types.MethodType): assert isinstance(call, types.MethodType) - self._func = call.__func__ - self._obj = weakref.ref(call.__self__) + self.func = call.__func__ + self.obj = weakref.ref(call.__self__) def __call__(self, *args: Any, **keywds: Any) -> Any: - obj = self._obj() + obj: Any = self.obj() if obj is None: return None - return self._func(*((obj,) + args), **keywds) + return self.func(*((obj,) + args), **keywds) @override - def __str__(self) -> str: - return '' + def __repr__(self) -> str: + return f'' def verify_object_death(obj: object) -> None: @@ -292,7 +485,7 @@ def verify_object_death(obj: object) -> None: # Make this timer in an empty context; don't want it dying with the # scene/etc. with _babase.ContextRef.empty(): - _babase.apptimer(delay, Call(_verify_object_death, ref)) + _babase.apptimer(delay, CallStrict(_verify_object_death, ref)) def _verify_object_death(wref: weakref.ref) -> None: diff --git a/dist/ba_data/python/babase/_hooks.py b/dist/ba_data/python/babase/_hooks.py index 4c9b771..e5df00d 100644 --- a/dist/ba_data/python/babase/_hooks.py +++ b/dist/ba_data/python/babase/_hooks.py @@ -9,6 +9,7 @@ until it broke at runtime. By instead defining such snippets here and then capturing references to them all at launch it is possible to allow linting and type-checking magic to happen and most issues will be caught immediately. """ + # (most of these are self-explanatory) # pylint: disable=missing-function-docstring from __future__ import annotations @@ -42,7 +43,7 @@ def get_v2_account_id() -> str | None: if account is not None: accountid = account.accountid # (Avoids mypy complaints when plus is not present) - assert isinstance(accountid, (str, type(None))) + assert isinstance(accountid, str | None) return accountid return None except Exception: @@ -461,3 +462,36 @@ def copy_dev_console_history() -> None: _babase.clipboard_set_text('\n'.join(lines)) _babase.screenmessage(Lstr(resource='copyConfirmText'), color=(0, 1, 0)) _babase.getsimplesound('gunCocking').play() + + +def v2_auth_request(global_app_instance_id: str) -> None | tuple[bool, str]: + """Kick off or process v2 auth requests. + + Return None if no results or (success, error/token) + """ + assert _babase.app.plus is not None + out: None | tuple[bool, str] = _babase.app.plus.accounts.auth_request( + global_app_instance_id + ) + return out + + +def v2_auth_data(token: str) -> None | tuple[str, str, dict]: + """Look up autheneticated v2 account data via a token.""" + assert _babase.in_logic_thread() + + classic = _babase.app.classic + if classic is None: + return None + + now = time.monotonic() + authdata = classic.v2_auth_datas.get(token) + if authdata is None or authdata.expire_time <= now: + return None + + # Success! + return ( + authdata.account_id, + authdata.account_tag, + authdata.player_profiles, + ) diff --git a/dist/ba_data/python/babase/_language.py b/dist/ba_data/python/babase/_language.py index b8f3c6b..7f381e4 100644 --- a/dist/ba_data/python/babase/_language.py +++ b/dist/ba_data/python/babase/_language.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Language related functionality.""" + from __future__ import annotations import os @@ -561,7 +562,7 @@ class Lstr: You should avoid doing this as much as possible and instead pass and store ``Lstr`` values. """ - return _babase.evaluate_lstr(self._get_json()) + return _babase.evaluate_lstr(self.as_json()) def is_flat_value(self) -> bool: """Return whether this instance represents a 'flat' value. @@ -573,22 +574,13 @@ class Lstr: """ return bool('v' in self.args and not self.args.get('s', [])) - def _get_json(self) -> str: - try: - return json.dumps(self.args, separators=(',', ':')) - except Exception: - from babase import _error - - applog.exception('_get_json failed for %s.', self.args) - return 'JSON_ERR' - - @override - def __str__(self) -> str: - return f'' + def as_json(self) -> str: + """Return the json dict representation of the Lstr.""" + return json.dumps(self.args, separators=(',', ':')) @override def __repr__(self) -> str: - return f'' + return f'' @staticmethod def from_json(json_string: str) -> babase.Lstr: @@ -616,7 +608,7 @@ def _add_to_attr_dict(dst: AttrDict, src: dict) -> None: ) _add_to_attr_dict(dst_dict, value) else: - if not isinstance(value, (float, int, bool, str, str, type(None))): + if not isinstance(value, float | int | bool | str | None): raise TypeError( "invalid value type for res '" + key diff --git a/dist/ba_data/python/babase/_locale.py b/dist/ba_data/python/babase/_locale.py index c8de05e..5371d13 100644 --- a/dist/ba_data/python/babase/_locale.py +++ b/dist/ba_data/python/babase/_locale.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Locale related functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING, override, assert_never @@ -144,6 +145,7 @@ class LocaleSubsystem(AppSubsystem): or rlocale is cls.TAMIL or rlocale is cls.THAI or rlocale is cls.VIETNAMESE + or rlocale is cls.JAPANESE ): # Return True only if we can display full unicode. return _babase.supports_unicode_display() diff --git a/dist/ba_data/python/babase/_mgen/enums.py b/dist/ba_data/python/babase/_mgen/enums.py index 697fe4d..ceb45dd 100644 --- a/dist/ba_data/python/babase/_mgen/enums.py +++ b/dist/ba_data/python/babase/_mgen/enums.py @@ -85,7 +85,9 @@ class Permission(Enum): class SpecialChar(Enum): - """Special characters the game can print.""" + """Special characters the engine can diplay. Note that this currently + needs to be manually kept in sync with bacommon.text.SpecialChar. + """ DOWN_ARROW = 0 UP_ARROW = 1 @@ -185,3 +187,7 @@ class SpecialChar(Enum): MIKIROG = 95 V2_LOGO = 96 CLOSE = 97 + SANTA_HAT = 98 + POTATO = 99 + PALM_TREE = 100 + BOXING_GLOVE = 101 diff --git a/dist/ba_data/python/babase/_net.py b/dist/ba_data/python/babase/_net.py index 16c1e7c..6cbaaa0 100644 --- a/dist/ba_data/python/babase/_net.py +++ b/dist/ba_data/python/babase/_net.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Networking related functionality.""" + from __future__ import annotations import socket diff --git a/dist/ba_data/python/babase/_ui.py b/dist/ba_data/python/babase/_ui.py index 55c895b..9956580 100644 --- a/dist/ba_data/python/babase/_ui.py +++ b/dist/ba_data/python/babase/_ui.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """UI related bits of babase.""" + from __future__ import annotations from typing import TYPE_CHECKING, override diff --git a/dist/ba_data/python/babase/modutils.py b/dist/ba_data/python/babase/modutils.py index 831e9dd..b822734 100644 --- a/dist/ba_data/python/babase/modutils.py +++ b/dist/ba_data/python/babase/modutils.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to modding.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/baclassic/_achievement.py b/dist/ba_data/python/baclassic/_achievement.py index 6641829..a6acf62 100644 --- a/dist/ba_data/python/baclassic/_achievement.py +++ b/dist/ba_data/python/baclassic/_achievement.py @@ -1,12 +1,13 @@ # Released under the MIT License. See LICENSE for details. # """Various functionality related to achievements.""" + from __future__ import annotations import logging from typing import TYPE_CHECKING -from bacommon.bs import ClassicChestAppearance +from bacommon.classic import ClassicChestAppearance from baclassic._chest import ( CHEST_APPEARANCE_DISPLAY_INFOS, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, @@ -727,7 +728,10 @@ class Achievement: ) def get_award_chest_type(self) -> ClassicChestAppearance: - """Return the type of chest given for this achievement.""" + """Return the type of chest given for this achievement. + + :meta private: + """ # For now just map our old ticket values to chest types. # Can add distinct values if need be later. @@ -1520,5 +1524,7 @@ class Achievement: for actor in objs: bascenev1.timer( out_time + 1.000, - babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()), + babase.WeakCallStrict( + actor.handlemessage, bascenev1.DieMessage() + ), ) diff --git a/dist/ba_data/python/baclassic/_ads.py b/dist/ba_data/python/baclassic/_ads.py deleted file mode 100644 index 25dabb2..0000000 --- a/dist/ba_data/python/baclassic/_ads.py +++ /dev/null @@ -1,227 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Functionality related to ads.""" -from __future__ import annotations - -import time -import asyncio -import logging -from typing import TYPE_CHECKING - -import babase -import bascenev1 - -if TYPE_CHECKING: - from typing import Callable, Any - - -class AdsSubsystem: - """Subsystem for ads functionality in the app. - - Access the single shared instance of this class at 'ba.app.ads'. - """ - - def __init__(self) -> None: - self.last_ad_network = 'unknown' - self.last_ad_network_set_time = time.time() - self.ad_amt: float | None = None - self.last_ad_purpose = 'invalid' - self.attempted_first_ad = False - self.last_in_game_ad_remove_message_show_time: float | None = None - self.last_ad_completion_time: float | None = None - self.last_ad_was_short = False - self._fallback_task: asyncio.Task | None = None - - def do_remove_in_game_ads_message(self) -> None: - """(internal)""" - - # Print this message once every 10 minutes at most. - tval = babase.apptime() - if self.last_in_game_ad_remove_message_show_time is None or ( - tval - self.last_in_game_ad_remove_message_show_time > 60 * 10 - ): - self.last_in_game_ad_remove_message_show_time = tval - with babase.ContextRef.empty(): - babase.apptimer( - 1.0, - lambda: babase.screenmessage( - babase.Lstr( - resource='removeInGameAdsTokenPurchaseText' - ), - color=(1, 1, 0), - ), - ) - - def show_ad( - self, purpose: str, on_completion_call: Callable[[], Any] | None = None - ) -> None: - """(internal)""" - self.last_ad_purpose = purpose - assert babase.app.plus is not None - babase.app.plus.show_ad(purpose, on_completion_call) - - def show_ad_2( - self, - purpose: str, - on_completion_call: Callable[[bool], Any] | None = None, - ) -> None: - """(internal)""" - self.last_ad_purpose = purpose - assert babase.app.plus is not None - babase.app.plus.show_ad_2(purpose, on_completion_call) - - def call_after_ad(self, call: Callable[[], Any]) -> None: - """Run a call after potentially showing an ad.""" - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - - app = babase.app - plus = app.plus - classic = app.classic - assert plus is not None - assert classic is not None - show = True - - # No ads without net-connections, etc. - if not plus.can_show_ad(): - show = False - - # Pro or other upgrades disable interstitials. - if ( - classic.accounts.have_pro() - or classic.gold_pass - or classic.remove_ads - ): - show = False - try: - session = bascenev1.get_foreground_host_session() - assert session is not None - is_tournament = session.tournament_id is not None - except Exception: - is_tournament = False - if is_tournament: - show = False # Never show ads during tournaments. - - if show: - interval: float | None - launch_count = app.config.get('launchCount', 0) - - # If we're seeing short ads we may want to space them differently. - interval_mult = ( - plus.get_v1_account_misc_read_val('ads.shortIntervalMult', 1.0) - if self.last_ad_was_short - else 1.0 - ) - if self.ad_amt is None: - if launch_count <= 1: - self.ad_amt = plus.get_v1_account_misc_read_val( - 'ads.startVal1', 0.99 - ) - else: - self.ad_amt = plus.get_v1_account_misc_read_val( - 'ads.startVal2', 1.0 - ) - interval = None - else: - # So far we're cleared to show; now calc our - # ad-show-threshold and see if we should *actually* show - # (we reach our threshold faster the longer we've been - # playing). - base = 'ads' if plus.has_video_ads() else 'ads2' - min_lc = plus.get_v1_account_misc_read_val(base + '.minLC', 0.0) - max_lc = plus.get_v1_account_misc_read_val(base + '.maxLC', 5.0) - min_lc_scale = plus.get_v1_account_misc_read_val( - base + '.minLCScale', 0.25 - ) - max_lc_scale = plus.get_v1_account_misc_read_val( - base + '.maxLCScale', 0.34 - ) - min_lc_interval = plus.get_v1_account_misc_read_val( - base + '.minLCInterval', 360 - ) - max_lc_interval = plus.get_v1_account_misc_read_val( - base + '.maxLCInterval', 300 - ) - if launch_count < min_lc: - lc_amt = 0.0 - elif launch_count > max_lc: - lc_amt = 1.0 - else: - lc_amt = (float(launch_count) - min_lc) / (max_lc - min_lc) - incr = (1.0 - lc_amt) * min_lc_scale + lc_amt * max_lc_scale - interval = ( - 1.0 - lc_amt - ) * min_lc_interval + lc_amt * max_lc_interval - self.ad_amt += incr - assert self.ad_amt is not None - if self.ad_amt >= 1.0: - self.ad_amt = self.ad_amt % 1.0 - self.attempted_first_ad = True - - # After we've reached the traditional show-threshold once, - # try again whenever its been INTERVAL since our last successful - # show. - elif self.attempted_first_ad and ( - self.last_ad_completion_time is None - or ( - interval is not None - and babase.apptime() - self.last_ad_completion_time - > (interval * interval_mult) - ) - ): - # Reset our other counter too in this case. - self.ad_amt = 0.0 - else: - show = False - - # If we're *still* cleared to show, actually tell the system to show. - if show: - # As a safety-check, we set up an object that will run the - # completion callback if we've returned and sat for several - # seconds (in case some random ad network doesn't properly - # deliver its completion callback). - class _Payload: - def __init__(self, pcall: Callable[[], Any]): - self._call = pcall - self._ran = False - - def run(self, fallback: bool = False) -> None: - """Run the payload.""" - assert app.classic is not None - if not self._ran: - if fallback: - lanst = app.classic.ads.last_ad_network_set_time - logging.error( - 'Relying on fallback ad-callback! ' - 'last network: %s (set %s seconds ago);' - ' purpose=%s.', - app.classic.ads.last_ad_network, - time.time() - lanst, - app.classic.ads.last_ad_purpose, - ) - babase.pushcall(self._call) - self._ran = True - - payload = _Payload(call) - - # Set up our backup. - with babase.ContextRef.empty(): - # Note to self: Previously this was a simple 5 second - # timer because the app got totally suspended while ads - # were showing (which delayed the timer), but these days - # the app may continue to run, so we need to be more - # careful and only fire the fallback after we see that - # the app has been front-and-center for several seconds. - async def add_fallback_task() -> None: - activesecs = 5 - while activesecs > 0: - if babase.app.active: - activesecs -= 1 - await asyncio.sleep(1.0) - payload.run(fallback=True) - - babase.app.create_async_task(add_fallback_task()) - self.show_ad('between_game', on_completion_call=payload.run) - else: - babase.pushcall(call) # Just run the callback without the ad. diff --git a/dist/ba_data/python/baclassic/_analytics.py b/dist/ba_data/python/baclassic/_analytics.py index 41a68a8..ad5fe8a 100644 --- a/dist/ba_data/python/baclassic/_analytics.py +++ b/dist/ba_data/python/baclassic/_analytics.py @@ -1,6 +1,6 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to analytics.""" +"""Functionality related to classic analytics.""" from __future__ import annotations diff --git a/dist/ba_data/python/baclassic/_appmode.py b/dist/ba_data/python/baclassic/_appmode.py index ce065b1..20246ba 100644 --- a/dist/ba_data/python/baclassic/_appmode.py +++ b/dist/ba_data/python/baclassic/_appmode.py @@ -1,5 +1,6 @@ # Released under the MIT License. See LICENSE for details. # +# pylint: disable=too-many-lines """Contains ClassicAppMode.""" from __future__ import annotations @@ -11,11 +12,11 @@ from functools import partial from typing import TYPE_CHECKING, override from efro.error import CommunicationError -import bacommon.bs +import bacommon.clienteffect as clfx +import bacommon.classic from babase import AppMode import bauiv1 as bui from bauiv1lib.connectivity import wait_for_connectivity -from bauiv1lib.account.signin import show_sign_in_prompt import _baclassic @@ -233,19 +234,19 @@ class ClassicAppMode(AppMode): if item_id.startswith('tokens'): if item_id == 'tokens1': - tokens = bacommon.bs.TOKENS1_COUNT + tokens = bacommon.classic.TOKENS1_COUNT tokens_str = str(tokens) anim_time = 2.0 elif item_id == 'tokens2': - tokens = bacommon.bs.TOKENS2_COUNT + tokens = bacommon.classic.TOKENS2_COUNT tokens_str = str(tokens) anim_time = 2.5 elif item_id == 'tokens3': - tokens = bacommon.bs.TOKENS3_COUNT + tokens = bacommon.classic.TOKENS3_COUNT tokens_str = str(tokens) anim_time = 3.0 elif item_id == 'tokens4': - tokens = bacommon.bs.TOKENS4_COUNT + tokens = bacommon.classic.TOKENS4_COUNT tokens_str = str(tokens) anim_time = 3.5 else: @@ -257,21 +258,19 @@ class ClassicAppMode(AppMode): ) assert bui.app.classic is not None - effects: list[bacommon.bs.ClientEffect] = [ - bacommon.bs.ClientEffectTokensAnimation( + effects: list[clfx.Effect] = [ + clfx.TokensAnimation( duration=anim_time, startvalue=self._last_tokens_value, endvalue=self._last_tokens_value + tokens, ), - bacommon.bs.ClientEffectDelay(anim_time), - bacommon.bs.ClientEffectScreenMessage( + clfx.Delay(anim_time), + clfx.LegacyScreenMessage( message='You got ${COUNT} tokens!', subs=['${COUNT}', tokens_str], color=(0, 1, 0), ), - bacommon.bs.ClientEffectSound( - sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER - ), + clfx.PlaySound(clfx.Sound.CASH_REGISTER), ] bui.app.classic.run_bs_client_effects(effects) @@ -345,14 +344,14 @@ class ClassicAppMode(AppMode): with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.GetClassicPurchasesMessage(), - on_response=bui.WeakCall( + bacommon.classic.GetClassicPurchasesMessage(), + on_response=bui.WeakCallPartial( self._on_get_classic_purchases_response ), ) def _on_get_classic_purchases_response( - self, response: bacommon.bs.GetClassicPurchasesResponse | Exception + self, response: bacommon.classic.GetClassicPurchasesResponse | Exception ) -> None: assert self._purchase_request_in_flight self._purchase_request_in_flight = False @@ -471,6 +470,7 @@ class ClassicAppMode(AppMode): chest_1_ad_allow_time=-1.0, chest_2_ad_allow_time=-1.0, chest_3_ad_allow_time=-1.0, + store_style='', ) self._have_account_values = False self._update_ui_live_state() @@ -505,7 +505,7 @@ class ClassicAppMode(AppMode): print(f'GOT SUB TEST UPDATE: {val}') def _on_classic_account_data_change( - self, val: bacommon.bs.ClassicAccountLiveData + self, val: bacommon.classic.ClassicLiveAccountClientData ) -> None: achp = round(val.achievements / max(val.achievements_total, 1) * 100.0) @@ -666,6 +666,7 @@ class ClassicAppMode(AppMode): if chest3 is None or chest3.ad_allow_time is None else chest3.ad_allow_time.timestamp() ), + store_style=val.store_style.value, ) # Note that we have values and updated faded state accordingly. @@ -723,44 +724,56 @@ class ClassicAppMode(AppMode): def _root_ui_achievements_press(self) -> None: from bauiv1lib.achievements import AchievementsWindow - if not self._ensure_signed_in_v1(): + btn = bui.get_special_widget('achievements_button') + + if not self._ensure_signed_in(origin_widget=btn): return wait_for_connectivity( on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=AchievementsWindow, - win_create_call=lambda: AchievementsWindow( - origin_widget=bui.get_special_widget('achievements_button') - ), + win_create_call=lambda: AchievementsWindow(origin_widget=btn), ) ) def _root_ui_inbox_press(self) -> None: from bauiv1lib.inbox import InboxWindow - if not self._ensure_signed_in(): + btn = bui.get_special_widget('inbox_button') + + if not self._ensure_signed_in(origin_widget=btn): return wait_for_connectivity( on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=InboxWindow, - win_create_call=lambda: InboxWindow( - origin_widget=bui.get_special_widget('inbox_button') - ), + win_create_call=lambda: InboxWindow(origin_widget=btn), ) ) def _root_ui_store_press(self) -> None: - from bauiv1lib.store.browser import StoreBrowserWindow + import bacommon.docui.v1 as dui1 - if not self._ensure_signed_in_v1(): + from bauiv1lib.docui import DocUIWindow + from bauiv1lib.store import StoreUIController + + btn = bui.get_special_widget('store_button') + + if not self._ensure_signed_in(origin_widget=btn): return + # Pop up an auxiliary window wherever we are in the nav stack. wait_for_connectivity( on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( - win_type=StoreBrowserWindow, - win_create_call=lambda: StoreBrowserWindow( - origin_widget=bui.get_special_widget('store_button') + win_type=DocUIWindow, + win_create_call=bui.CallStrict( + StoreUIController().create_window, + dui1.Request('/'), + origin_widget=btn, + uiopenstateid='classicstore', + ), + win_extra_type_id=( + StoreUIController.get_window_extra_type_id() ), ) ) @@ -782,80 +795,76 @@ class ClassicAppMode(AppMode): def _root_ui_trophy_meter_press(self) -> None: from bauiv1lib.league.rankwindow import LeagueRankWindow - if not self._ensure_signed_in_v1(): + btn = bui.get_special_widget('trophy_meter') + + if not self._ensure_signed_in(origin_widget=btn): return bui.app.ui_v1.auxiliary_window_activate( win_type=LeagueRankWindow, - win_create_call=lambda: LeagueRankWindow( - origin_widget=bui.get_special_widget('trophy_meter') - ), + win_create_call=lambda: LeagueRankWindow(origin_widget=btn), ) def _root_ui_level_meter_press(self) -> None: from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow - ResourceTypeInfoWindow( - 'xp', origin_widget=bui.get_special_widget('level_meter') - ) + btn = bui.get_special_widget('level_meter') - def _root_ui_inventory_press(self) -> None: - from bauiv1lib.inventory import InventoryWindow - - if not self._ensure_signed_in_v1(): + if not self._ensure_signed_in(origin_widget=btn): return + ResourceTypeInfoWindow('xp', origin_widget=btn) + + def _root_ui_inventory_press(self) -> None: + import bacommon.docui.v1 as dui1 + + from bauiv1lib.docui import DocUIWindow + from bauiv1lib.inventory import InventoryUIController + + # Pop up an auxiliary window wherever we are in the nav stack. bui.app.ui_v1.auxiliary_window_activate( - win_type=InventoryWindow, - win_create_call=lambda: InventoryWindow( - origin_widget=bui.get_special_widget('inventory_button') + win_type=DocUIWindow, + win_create_call=bui.CallStrict( + InventoryUIController().create_window, + dui1.Request('/'), + origin_widget=bui.get_special_widget('inventory_button'), + uiopenstateid='classicinventory', ), + win_extra_type_id=InventoryUIController.get_window_extra_type_id(), ) - def _ensure_signed_in(self) -> bool: + def _ensure_signed_in(self, *, origin_widget: bui.Widget | None) -> bool: """Make sure we're signed in (requiring modern v2 accounts).""" + from bauiv1lib.account.signin import show_sign_in_prompt + plus = bui.app.plus if plus is None: bui.screenmessage('This requires plus.', color=(1, 0, 0)) bui.getsound('error').play() return False if plus.accounts.primary is None: - show_sign_in_prompt() - return False - return True - - def _ensure_signed_in_v1(self) -> bool: - """Make sure we're signed in (allowing legacy v1-only accounts).""" - plus = bui.app.plus - if plus is None: - bui.screenmessage('This requires plus.', color=(1, 0, 0)) - bui.getsound('error').play() - return False - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() + show_sign_in_prompt(origin_widget=origin_widget) return False return True def _root_ui_get_tokens_press(self) -> None: - from bauiv1lib.gettokens import GetTokensWindow + from bauiv1lib.gettokens import GetTokensWindow, show_get_tokens_window - if not self._ensure_signed_in(): + btn = bui.get_special_widget('get_tokens_button') + + if not self._ensure_signed_in(origin_widget=btn): return - bui.app.ui_v1.auxiliary_window_activate( - win_type=GetTokensWindow, - win_create_call=lambda: GetTokensWindow( - origin_widget=bui.get_special_widget('get_tokens_button') - ), - ) + if bool(True): + show_get_tokens_window(origin_widget=btn, toggle=True) + else: + bui.app.ui_v1.auxiliary_window_activate( + win_type=GetTokensWindow, + win_create_call=lambda: GetTokensWindow(origin_widget=btn), + ) def _root_ui_chest_slot_pressed(self, index: int) -> None: - from bauiv1lib.chest import ( - ChestWindow0, - ChestWindow1, - ChestWindow2, - ChestWindow3, - ) + from bauiv1lib.chest import ChestWindow widgetid: Literal[ 'chest_0_button', @@ -866,16 +875,20 @@ class ClassicAppMode(AppMode): winclass: type[ChestWindow] if index == 0: widgetid = 'chest_0_button' - winclass = ChestWindow0 + winclass = ChestWindow + extratypeid = '0' elif index == 1: widgetid = 'chest_1_button' - winclass = ChestWindow1 + winclass = ChestWindow + extratypeid = '1' elif index == 2: widgetid = 'chest_2_button' - winclass = ChestWindow2 + winclass = ChestWindow + extratypeid = '2' elif index == 3: widgetid = 'chest_3_button' - winclass = ChestWindow3 + winclass = ChestWindow + extratypeid = '3' else: raise RuntimeError(f'Invalid index {index}') @@ -886,6 +899,7 @@ class ClassicAppMode(AppMode): index=index, origin_widget=bui.get_special_widget(widgetid), ), + win_extra_type_id=extratypeid, ) ) @@ -953,16 +967,26 @@ class ClassicAppMode(AppMode): return [ bui.DevConsoleButtonDef( 'MainWindow Template', - bui.WeakCall(self._main_win_template_press), + bui.WeakCallStrict(self._main_win_template_press), ), bui.DevConsoleButtonDef( - 'CloudUI Test', bui.WeakCall(self._cloud_ui_test_press) + 'DocUI Test', bui.WeakCallStrict(self._doc_ui_test_press) ), ] def _main_win_template_press(self) -> None: from bauiv1lib.template import show_template_main_window + # This only works if a main ui is up. + if bui.app.ui_v1.get_main_window() is None: + bui.screenmessage( + 'This requires a main-window to be present.' + ' Open a menu or whatnot first.', + color=(1, 0, 0), + ) + bui.getsound('error').play() + return + # Unintuitively, swish sounds come from buttons, not windows. # And dev-console buttons don't make sounds. So we need to # explicitly do so here. @@ -970,12 +994,22 @@ class ClassicAppMode(AppMode): show_template_main_window() - def _cloud_ui_test_press(self) -> None: - from bauiv1 import show_cloud_ui_window + def _doc_ui_test_press(self) -> None: + from bauiv1lib.docuitest import show_test_doc_ui_window + + # This only works if a main ui is up. + if bui.app.ui_v1.get_main_window() is None: + bui.screenmessage( + 'This requires a main-window to be present.' + ' Open a menu or whatnot first.', + color=(1, 0, 0), + ) + bui.getsound('error').play() + return # Unintuitively, swish sounds come from buttons, not windows. # And dev-console buttons don't make sounds. So we need to # explicitly do so here. bui.getsound('swish').play() - show_cloud_ui_window() + show_test_doc_ui_window() diff --git a/dist/ba_data/python/baclassic/_appsubsystem.py b/dist/ba_data/python/baclassic/_appsubsystem.py index 9244069..9f7ac83 100644 --- a/dist/ba_data/python/baclassic/_appsubsystem.py +++ b/dist/ba_data/python/baclassic/_appsubsystem.py @@ -3,12 +3,15 @@ # pylint: disable=too-many-lines """Provides classic app subsystem.""" + from __future__ import annotations +import time import random import logging import weakref -from typing import TYPE_CHECKING, override, assert_never +from dataclasses import dataclass +from typing import TYPE_CHECKING, override, assert_never, final from efro.dataclassio import dataclass_from_dict import babase @@ -25,9 +28,12 @@ from baclassic._store import StoreSubsystem from baclassic import _input if TYPE_CHECKING: - from typing import Callable, Any, Sequence + import datetime + from typing import Callable, Any, Sequence, Awaitable - import bacommon.bs + import bacommon.classic + import bacommon.clienteffect as clfx + import bacommon.clouddialog.basic as bcdlg from bascenev1lib.actor import spazappearance from bauiv1lib.party import PartyWindow @@ -36,16 +42,72 @@ if TYPE_CHECKING: class ClassicAppSubsystem(babase.AppSubsystem): - """Subsystem for classic functionality in the app. + """Subsystem for classic bombsquad functionality in the app. The single shared instance of this app can be accessed at - babase.app.classic. Note that it is possible for babase.app.classic to - be None if the classic package is not present, and code should handle - that case gracefully. + babase.app.classic. Note that it is possible for babase.app.classic + to be None if the classic package is not present, and futureproof + code should handle that case gracefully. """ # pylint: disable=too-many-public-methods + @dataclass + class V2AuthRequest: + """What is passed in to V2 auth handler.""" + + #: V2 account id of the connecting account (a-XXX). + account_id: str + + #: Globally unique tag of the connecting account. + account_tag: str + + #: When the connecting account was created. + account_create_time: datetime.datetime + + #: Total number of days the connecting account has been active. + account_total_active_days: int + + #: An abstract value generated from the connecting client's + #: app-instance-id. Can be used to identify repeat connection + #: attempts/etc. Note that this value changes each time the + #: client re-launches the app. + app_instance_signature: str + + #: An abstract value generated from the connecting client's ip + #: address. Can be used to identify repeat connection + #: attempts/etc. + address_signature: str + + #: An abstract value generated from the connecting client's + #: device. Can be used to identify repeat connection + #: attempts/etc. This value may change over time for a given + #: device but should be mostly constant. + device_signature: str + + @dataclass + class V2AuthResponse: + """What a V2 auth handler returns.""" + + #: Whether to allow this client to enter the server. + allow: bool + + #: A message to be shown to the client if allow is False. A + #: defualt rejection message will be shown if none is present + #: here. This will be translated using the 'serverResponses' + #: translation category so it can be good to use one of the + #: entries there if your server has multilingual users. + error_message: str | None = None + + @dataclass + class V2AuthData: + """Authenticated data we store for accepted clients.""" + + account_id: str + account_tag: str + player_profiles: dict + expire_time: float + from baclassic._music import MusicPlayMode def __init__(self) -> None: @@ -92,6 +154,21 @@ class ClassicAppSubsystem(babase.AppSubsystem): # Server Mode. self.server: ServerController | None = None + #: V2 authentication handler. + #: + #: To customize who is allowed in your server, assign your own + #: custom handler function to this attribute. This will be called + #: for all connecting clients before they are allowed in the + #: game. Note that protocol must be set to 36 or newer and + #: authenticate_clients must be enabled. For servers you can set + #: those values in the server config. + self.v2_auth_handler: Callable[ + [ClassicAppSubsystem.V2AuthRequest], + Awaitable[ClassicAppSubsystem.V2AuthResponse], + ] = self.default_v2_auth_handler + self.v2_auth_datas: dict[str, ClassicAppSubsystem.V2AuthData] = {} + + # Logging/debugging. self.log_have_new = False self.log_upload_timer_started = False self.printed_live_object_warning = False @@ -126,8 +203,48 @@ class ClassicAppSubsystem(babase.AppSubsystem): self.pro_sale_start_time: int | None = None self.pro_sale_start_val: int | None = None + async def default_v2_auth_handler( + self, request: V2AuthRequest + ) -> V2AuthResponse: + """Default auth handler function. Just allows everyone.""" + + # A custom handler would look at request here to determine + # whether to let this client in. + del request # Unused. + + return self.V2AuthResponse(allow=True) + + @final + async def run_v2_auth_handler( + self, request: V2AuthRequest, player_profiles: Any, token: str + ) -> V2AuthResponse: + """:meta private:""" + assert babase.in_logic_thread() + result = await self.v2_auth_handler(request) + + # If it was accepted, keep their auth data around just long enough + # for them to connect. + if result.allow: + now = time.monotonic() + self.v2_auth_datas[token] = self.V2AuthData( + account_id=request.account_id, + account_tag=request.account_tag, + player_profiles=player_profiles, + expire_time=now + 30.0, + ) + + # Lazily prune expired auth-data. + if any(a.expire_time <= now for a in self.v2_auth_datas.values()): + self.v2_auth_datas = { + k: v + for k, v in self.v2_auth_datas.items() + if v.expire_time > now + } + + return result + def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: - """(internal)""" + """:meta private:""" # If there's no main window up, just call immediately. if not babase.app.ui_v1.has_main_window(): @@ -171,7 +288,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): return self._env['platform'] def scene_v1_protocol_version(self) -> int: - """(internal)""" + """:meta private:""" return bascenev1.protocol_version() @property @@ -408,7 +525,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): # Otherwise just force the issue. else: babase.pushcall( - babase.Call(bascenev1.new_host_session, MainMenuSession) + babase.CallStrict(bascenev1.new_host_session, MainMenuSession) ) def getmaps(self, playtype: str) -> list[str]: @@ -461,7 +578,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): ) def game_begin_analytics(self) -> None: - """(internal)""" + """:meta private:""" from baclassic import _analytics _analytics.game_begin_analytics() @@ -653,11 +770,11 @@ class ClassicAppSubsystem(babase.AppSubsystem): return bascenev1.get_player_profile_colors(profilename, profiles) def get_foreground_host_session(self) -> bascenev1.Session | None: - """(internal)""" + """:meta private:""" return bascenev1.get_foreground_host_session() def get_foreground_host_activity(self) -> bascenev1.Activity | None: - """(internal)""" + """:meta private:""" return bascenev1.get_foreground_host_activity() def value_test( @@ -666,26 +783,26 @@ class ClassicAppSubsystem(babase.AppSubsystem): change: float | None = None, absolute: float | None = None, ) -> float: - """(internal)""" + """:meta private:""" return _baclassic.value_test(arg, change, absolute) def set_master_server_source(self, source: int) -> None: - """(internal)""" + """:meta private:""" bascenev1.set_master_server_source(source) def get_game_port(self) -> int: - """(internal)""" + """:meta private:""" return bascenev1.get_game_port() def v2_upgrade_window(self, login_name: str, code: str) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.v2upgrade import V2UpgradeWindow V2UpgradeWindow(login_name, code) def server_dialog(self, delay: float, data: dict[str, Any]) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.serverdialog import ( ServerDialogData, ServerDialogWindow, @@ -702,17 +819,17 @@ class ClassicAppSubsystem(babase.AppSubsystem): if sddata is not None: babase.apptimer( delay, - babase.Call(ServerDialogWindow, sddata), + babase.CallStrict(ServerDialogWindow, sddata), ) def show_url_window(self, address: str) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.url import ShowURLWindow ShowURLWindow(address) def quit_window(self, quit_type: babase.QuitType) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.confirm import QuitWindow QuitWindow(quit_type) @@ -728,7 +845,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): offset: tuple[float, float] = (0.0, 0.0), on_close_call: Callable[[], Any] | None = None, ) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.tournamententry import TournamentEntryWindow TournamentEntryWindow( @@ -742,7 +859,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): ) def get_main_menu_session(self) -> type[bascenev1.Session]: - """(internal)""" + """:meta private:""" from bascenev1lib.mainmenu import MainMenuSession return MainMenuSession @@ -751,10 +868,13 @@ class ClassicAppSubsystem(babase.AppSubsystem): self, transition: str = 'in_right', origin_widget: bauiv1.Widget | None = None, - selected_profile: str | None = None, + # selected_profile: str | None = None, ) -> None: """Pop up a browser window from within a game.""" - from bauiv1lib.profile.browser import ProfileBrowserWindow + import bacommon.docui.v1 as dui1 + + # from bauiv1lib.profile.browser import ProfileBrowserWindow + from bauiv1lib.inventory import InventoryUIController main_window = babase.app.ui_v1.get_main_window() if main_window is not None: @@ -765,15 +885,16 @@ class ClassicAppSubsystem(babase.AppSubsystem): return babase.app.ui_v1.set_main_window( - ProfileBrowserWindow( + InventoryUIController(player_profiles_only=True).create_window( + dui1.Request('/'), + uiopenstateid='classicinventory', transition=transition, - selected_profile=selected_profile, origin_widget=origin_widget, - minimal_toolbar=True, ), is_top_level=True, back_state=None, suppress_warning=True, + extra_type_id=InventoryUIController.get_window_extra_type_id(), ) def preload_map_preview_media(self) -> None: @@ -789,7 +910,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): logging.exception('Error preloading map preview media.') def party_icon_activate(self, origin: Sequence[float]) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.party import PartyWindow from babase import app @@ -810,7 +931,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): self.party_window = weakref.ref(PartyWindow(origin=origin)) def request_main_ui(self) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.ingamemenu import InGameMenuWindow assert babase.app is not None @@ -835,6 +956,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): suppress_warning=True, # Reset selections to default for consistency. restore_shared_state=False, + extra_type_id='', ) def save_ui_state(self) -> None: @@ -876,11 +998,17 @@ class ClassicAppSubsystem(babase.AppSubsystem): is_top_level=True, back_state=None, suppress_warning=True, + extra_type_id='', ) else: # If there's a saved ui state, restore that. if self.saved_ui_state is not None: app.ui_v1.restore_main_window_state(self.saved_ui_state) + # Kill the state now that we're back; we'll + # generate a new one when we leave. This keeps + # UIOpenStates stored in the state doing the + # right thing. + self.saved_ui_state = None else: # Otherwise start fresh at the main menu. from bauiv1lib.mainmenu import MainMenuWindow @@ -890,25 +1018,32 @@ class ClassicAppSubsystem(babase.AppSubsystem): is_top_level=True, back_state=None, suppress_warning=True, + extra_type_id='', ) @staticmethod def run_bs_client_effects( - effects: list[bacommon.bs.ClientEffect], delay: float = 0.0 + effects: list[clfx.Effect], delay: float = 0.0 ) -> None: - """Run client effects sent from the master server.""" + """Run client effects sent from the master server. + + :meta private: + """ from baclassic._clienteffect import run_bs_client_effects run_bs_client_effects(effects, delay=delay) @staticmethod def basic_client_ui_button_label_str( - label: bacommon.bs.BasicCloudDialog.ButtonLabel, + label: bcdlg.ButtonLabel, ) -> babase.Lstr: - """Given a client-ui label, return an Lstr.""" - import bacommon.bs + """Given a client-ui label, return an Lstr. - cls = bacommon.bs.BasicCloudDialog.ButtonLabel + :meta private: + """ + import bacommon.clouddialog.basic as bcdlg + + cls = bcdlg.ButtonLabel if label is cls.UNKNOWN: # Server should not be sending us unknown stuff; make noise # if they do. diff --git a/dist/ba_data/python/baclassic/_benchmark.py b/dist/ba_data/python/baclassic/_benchmark.py index b110e25..91be0c6 100644 --- a/dist/ba_data/python/baclassic/_benchmark.py +++ b/dist/ba_data/python/baclassic/_benchmark.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Benchmark/Stress-Test related functionality.""" + from __future__ import annotations import random @@ -130,9 +131,9 @@ def _start_stress_test(args: _StressTestArgs) -> None: appconfig['Team Tournament Playlist Randomize'] = 1 babase.apptimer( 1.0, - babase.Call( + babase.CallStrict( babase.pushcall, - babase.Call(bascenev1.new_host_session, DualTeamSession), + babase.CallStrict(bascenev1.new_host_session, DualTeamSession), ), ) else: @@ -140,18 +141,22 @@ def _start_stress_test(args: _StressTestArgs) -> None: appconfig['Free-for-All Playlist Randomize'] = 1 babase.apptimer( 1.0, - babase.Call( + babase.CallStrict( babase.pushcall, - babase.Call(bascenev1.new_host_session, FreeForAllSession), + babase.CallStrict( + bascenev1.new_host_session, FreeForAllSession + ), ), ) _baclassic.set_stress_testing(True, args.player_count, args.attract_mode) classic.stress_test_update_timer = babase.AppTimer( - args.round_duration, babase.Call(_reset_stress_test, args) + args.round_duration, babase.CallStrict(_reset_stress_test, args) ) if args.attract_mode: classic.stress_test_update_timer_2 = babase.AppTimer( - 0.48, babase.Call(_update_attract_mode_test, args), repeat=True + 0.48, + babase.CallStrict(_update_attract_mode_test, args), + repeat=True, ) @@ -176,7 +181,7 @@ def _reset_stress_test(args: _StressTestArgs) -> None: # we just end back at the main menu. If things are idle there then # we'll get sent back to a new stress test. if not args.attract_mode: - babase.apptimer(1.0, babase.Call(_start_stress_test, args)) + babase.apptimer(1.0, babase.CallStrict(_start_stress_test, args)) def run_media_reload_benchmark() -> None: @@ -200,8 +205,8 @@ def run_media_reload_benchmark() -> None: color=(1, 1, 0), ) - babase.add_clean_frame_callback(babase.Call(doit, start_time)) + babase.add_clean_frame_callback(babase.CallStrict(doit, start_time)) # The reload starts (should add a completion callback to the reload # func to fix this). - babase.apptimer(0.05, babase.Call(delay_add, babase.apptime())) + babase.apptimer(0.05, babase.CallStrict(delay_add, babase.apptime())) diff --git a/dist/ba_data/python/baclassic/_chest.py b/dist/ba_data/python/baclassic/_chest.py index b5e18ce..0fed2f6 100644 --- a/dist/ba_data/python/baclassic/_chest.py +++ b/dist/ba_data/python/baclassic/_chest.py @@ -1,12 +1,13 @@ # Released under the MIT License. See LICENSE for details. # """Chest related functionality.""" + from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING -from bacommon.bs import ClassicChestAppearance +from bacommon.classic import ClassicChestAppearance if TYPE_CHECKING: pass diff --git a/dist/ba_data/python/baclassic/_clienteffect.py b/dist/ba_data/python/baclassic/_clienteffect.py index 50e3be7..da91995 100644 --- a/dist/ba_data/python/baclassic/_clienteffect.py +++ b/dist/ba_data/python/baclassic/_clienteffect.py @@ -9,26 +9,25 @@ from typing import TYPE_CHECKING, assert_never from efro.util import strict_partial -import bacommon.bs import bauiv1 import _baclassic if TYPE_CHECKING: - pass + import bacommon.clienteffect as clfx def run_bs_client_effects( - effects: list[bacommon.bs.ClientEffect], delay: float = 0.0 + effects: list[clfx.Effect], delay: float = 0.0 ) -> None: """Run effects.""" # pylint: disable=too-many-branches - from bacommon.bs import ClientEffectTypeID + import bacommon.clienteffect as clfx for effect in effects: effecttype = effect.get_type_id() - if effecttype is ClientEffectTypeID.SCREEN_MESSAGE: - assert isinstance(effect, bacommon.bs.ClientEffectScreenMessage) + if effecttype is clfx.EffectTypeID.LEGACY_SCREEN_MESSAGE: + assert isinstance(effect, clfx.LegacyScreenMessage) textfin = bauiv1.Lstr( translate=('serverResponses', effect.message) ).evaluate() @@ -46,22 +45,33 @@ def run_bs_client_effects( bauiv1.screenmessage, textfin, color=effect.color ), ) + elif effecttype is clfx.EffectTypeID.SCREEN_MESSAGE: + assert isinstance(effect, clfx.ScreenMessage) + bauiv1.apptimer( + delay, + strict_partial( + bauiv1.screenmessage, + effect.message, + color=effect.color, + literal=not effect.is_lstr, + ), + ) - elif effecttype is ClientEffectTypeID.SOUND: - assert isinstance(effect, bacommon.bs.ClientEffectSound) - smcls = bacommon.bs.ClientEffectSound.Sound + elif effecttype is clfx.EffectTypeID.SOUND: + assert isinstance(effect, clfx.PlaySound) + scls = clfx.Sound soundfile: str | None = None - if effect.sound is smcls.UNKNOWN: + if effect.sound is scls.UNKNOWN: # Server should avoid sending us sounds we don't # support. Make some noise if it happens. - logging.error('Got unrecognized bacommon.bs.ClientEffectSound.') - elif effect.sound is smcls.CASH_REGISTER: + logging.error('Got unrecognized bacommon.classic.Sound.') + elif effect.sound is scls.CASH_REGISTER: soundfile = 'cashRegister' - elif effect.sound is smcls.ERROR: + elif effect.sound is scls.ERROR: soundfile = 'error' - elif effect.sound is smcls.POWER_DOWN: + elif effect.sound is scls.POWER_DOWN: soundfile = 'powerdown01' - elif effect.sound is smcls.GUN_COCKING: + elif effect.sound is scls.GUN_COCKING: soundfile = 'gunCocking' else: assert_never(effect.sound) @@ -73,14 +83,12 @@ def run_bs_client_effects( ), ) - elif effecttype is ClientEffectTypeID.DELAY: - assert isinstance(effect, bacommon.bs.ClientEffectDelay) + elif effecttype is clfx.EffectTypeID.DELAY: + assert isinstance(effect, clfx.Delay) delay += effect.seconds - elif effecttype is ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION: - assert isinstance( - effect, bacommon.bs.ClientEffectChestWaitTimeAnimation - ) + elif effecttype is clfx.EffectTypeID.CHEST_WAIT_TIME_ANIMATION: + assert isinstance(effect, clfx.ChestWaitTimeAnimation) bauiv1.apptimer( delay, strict_partial( @@ -92,8 +100,8 @@ def run_bs_client_effects( ), ) - elif effecttype is ClientEffectTypeID.TICKETS_ANIMATION: - assert isinstance(effect, bacommon.bs.ClientEffectTicketsAnimation) + elif effecttype is clfx.EffectTypeID.TICKETS_ANIMATION: + assert isinstance(effect, clfx.TicketsAnimation) bauiv1.apptimer( delay, strict_partial( @@ -104,8 +112,8 @@ def run_bs_client_effects( ), ) - elif effecttype is ClientEffectTypeID.TOKENS_ANIMATION: - assert isinstance(effect, bacommon.bs.ClientEffectTokensAnimation) + elif effecttype is clfx.EffectTypeID.TOKENS_ANIMATION: + assert isinstance(effect, clfx.TokensAnimation) bauiv1.apptimer( delay, strict_partial( @@ -116,12 +124,11 @@ def run_bs_client_effects( ), ) - elif effecttype is ClientEffectTypeID.UNKNOWN: + elif effecttype is clfx.EffectTypeID.UNKNOWN: # Server should not send us stuff we can't digest. Make # some noise if it happens. logging.error( - 'Got unrecognized bacommon.bs.ClientEffect;' - ' should not happen.' + 'Got unrecognized bacommon.classic.Effect; should not happen.' ) else: diff --git a/dist/ba_data/python/baclassic/_displayitem.py b/dist/ba_data/python/baclassic/_displayitem.py index da8215c..195ddbd 100644 --- a/dist/ba_data/python/baclassic/_displayitem.py +++ b/dist/ba_data/python/baclassic/_displayitem.py @@ -1,28 +1,33 @@ # Released under the MIT License. See LICENSE for details. # """Display-item related functionality.""" + from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, assert_never from efro.util import pairs_from_flat -import bacommon.bs +import bacommon.displayitem as ditm +import bacommon.classic import bauiv1 - if TYPE_CHECKING: pass +# FIXME - migrate to use the doc-ui rendering for these instead. def show_display_item( - itemwrapper: bacommon.bs.DisplayItemWrapper, + itemwrapper: ditm.Wrapper, parent: bauiv1.Widget, pos: tuple[float, float], width: float, + debug: bool = False, ) -> None: """Create ui to depict a display-item.""" + # pylint: disable=too-many-locals - height = width * 0.666 + # Let's go with 4:3 aspect ratio. + height = width * 0.75 # Silent no-op if our parent ui is dead. if not parent: @@ -33,15 +38,24 @@ def show_display_item( text_y_offs = 0.0 show_text = True - if isinstance(itemwrapper.item, bacommon.bs.TicketsDisplayItem): + itemtype = itemwrapper.item.get_type_id() + + if itemtype is ditm.ItemTypeID.TICKETS: img = 'tickets' img_y_offs = width * 0.11 text_y_offs = width * -0.15 - elif isinstance(itemwrapper.item, bacommon.bs.TokensDisplayItem): + elif itemtype is ditm.ItemTypeID.TICKETS_PURPLE: + img = 'ticketsPurple' + img_y_offs = width * 0.11 + text_y_offs = width * -0.15 + elif itemtype is ditm.ItemTypeID.TOKENS: img = 'coin' img_y_offs = width * 0.11 text_y_offs = width * -0.15 - elif isinstance(itemwrapper.item, bacommon.bs.ChestDisplayItem): + elif itemtype is ditm.ItemTypeID.CHEST: + assert isinstance( + itemwrapper.item, bacommon.classic.ClassicChestDisplayItem + ) from baclassic._chest import ( CHEST_APPEARANCE_DISPLAY_INFOS, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, @@ -63,9 +77,14 @@ def show_display_item( tint_color=c_info.tint, tint2_color=c_info.tint2, ) + elif ( + itemtype is ditm.ItemTypeID.TEST or itemtype is ditm.ItemTypeID.UNKNOWN + ): + pass + else: + assert_never(itemtype) - # Enable this for testing spacing. - if bool(False): + if debug: bauiv1.imagewidget( parent=parent, position=( diff --git a/dist/ba_data/python/baclassic/_hooks.py b/dist/ba_data/python/baclassic/_hooks.py index 2b37f25..f5b002c 100644 --- a/dist/ba_data/python/baclassic/_hooks.py +++ b/dist/ba_data/python/baclassic/_hooks.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Hooks for C++ layer to use for ClassicAppMode.""" + from __future__ import annotations import logging diff --git a/dist/ba_data/python/baclassic/_input.py b/dist/ba_data/python/baclassic/_input.py index 0effcb5..feaa6d5 100644 --- a/dist/ba_data/python/baclassic/_input.py +++ b/dist/ba_data/python/baclassic/_input.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Input related functionality""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/baclassic/_music.py b/dist/ba_data/python/baclassic/_music.py index 5d63316..4a2ffce 100644 --- a/dist/ba_data/python/baclassic/_music.py +++ b/dist/ba_data/python/baclassic/_music.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Music related functionality.""" + from __future__ import annotations import copy diff --git a/dist/ba_data/python/baclassic/_net.py b/dist/ba_data/python/baclassic/_net.py index 4c1efc6..2ef3250 100644 --- a/dist/ba_data/python/baclassic/_net.py +++ b/dist/ba_data/python/baclassic/_net.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Networking related functionality.""" + from __future__ import annotations import zlib @@ -14,7 +15,7 @@ from typing import TYPE_CHECKING, override from efro.error import CommunicationError from efro.util import strip_exception_tracebacks -import bacommon.bs +import bacommon.classic import babase import bascenev1 @@ -113,7 +114,7 @@ class MasterServerV1CallThread(threading.Thread): dataenc = urllib.parse.urlencode(self._data) mresponse = plus.cloud.send_message( - bacommon.bs.LegacyRequest( + bacommon.classic.LegacyRequest( self._request, self._request_type, classic.legacy_user_agent_string, @@ -168,7 +169,7 @@ class MasterServerV1CallThread(threading.Thread): if self._callback is not None: babase.pushcall( - babase.Call(self._run_callback, response_data), + babase.CallStrict(self._run_callback, response_data), from_other_thread=True, ) diff --git a/dist/ba_data/python/baclassic/_servermode.py b/dist/ba_data/python/baclassic/_servermode.py index e0c39a0..a1eb898 100644 --- a/dist/ba_data/python/baclassic/_servermode.py +++ b/dist/ba_data/python/baclassic/_servermode.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to running the game in server-mode.""" + from __future__ import annotations import sys @@ -107,6 +108,12 @@ class ServerController: self._playlist_fetch_got_response = False self._playlist_fetch_code = -1 + # We do most configuration *after* we've fetched playlists or + # whatever other prep stuff we need to do, but this we should + # set immediately; otherwise unauthenticated clients can sneak + # into auth-enabled servers while they're bootstrapping. + bascenev1.set_authenticate_clients(self._config.authenticate_clients) + # Now sit around doing any pre-launch prep such as waiting for # account sign-in or fetching playlists; this will kick off the # session once done. @@ -427,8 +434,6 @@ class ServerController: classic.teams_series_length = self._config.teams_series_length classic.ffa_series_length = self._config.ffa_series_length - bascenev1.set_authenticate_clients(self._config.authenticate_clients) - bascenev1.set_enable_default_kick_voting( self._config.enable_default_kick_voting ) @@ -451,7 +456,6 @@ class ServerController: bascenev1.set_player_rejoin_cooldown( self._config.player_rejoin_cooldown ) - bascenev1.set_max_players_override( self._config.session_max_players_override ) @@ -470,6 +474,6 @@ class ServerController: bascenev1.new_host_session(sessiontype) # Run an access check if we're trying to make a public party. - if not self._ran_access_check : + if self._config.party_is_public and not self._ran_access_check: self._run_access_check() self._ran_access_check = True diff --git a/dist/ba_data/python/baclassic/_store.py b/dist/ba_data/python/baclassic/_store.py index 5dda5b5..6ed4dc5 100644 --- a/dist/ba_data/python/baclassic/_store.py +++ b/dist/ba_data/python/baclassic/_store.py @@ -292,6 +292,18 @@ class StoreSubsystem: 'icons.explodinary': { 'icon': babase.charstr(babase.SpecialChar.EXPLODINARY_LOGO) }, + 'icons.santa_hat': { + 'icon': babase.charstr(babase.SpecialChar.SANTA_HAT) + }, + 'icons.potato': { + 'icon': babase.charstr(babase.SpecialChar.POTATO) + }, + 'icons.palm_tree': { + 'icon': babase.charstr(babase.SpecialChar.PALM_TREE) + }, + 'icons.boxing_glove': { + 'icon': babase.charstr(babase.SpecialChar.BOXING_GLOVE) + }, } return babase.app.classic.store_items @@ -569,7 +581,7 @@ class StoreSubsystem: def get_unowned_maps(self) -> list[str]: """Return the list of local maps not owned by the current account.""" classic = babase.app.classic - purchases = classic.purchases if classic is not None else set() + purchases = classic.purchases if classic is not None else frozenset() unowned_maps: set[str] = set() if babase.app.env.gui: for map_section in self.get_store_layout()['maps']: @@ -583,7 +595,9 @@ class StoreSubsystem: """Return present game types not owned by the current account.""" try: classic = babase.app.classic - purchases = classic.purchases if classic is not None else set() + purchases = ( + classic.purchases if classic is not None else frozenset() + ) unowned_games: set[type[bascenev1.GameActivity]] = set() if babase.app.env.gui: for section in self.get_store_layout()['minigames']: diff --git a/dist/ba_data/python/baclassic/_tips.py b/dist/ba_data/python/baclassic/_tips.py index 5936c9c..ae18032 100644 --- a/dist/ba_data/python/baclassic/_tips.py +++ b/dist/ba_data/python/baclassic/_tips.py @@ -3,6 +3,7 @@ """Functionality related to classic game tips. These can be shown at opportune times such as between rounds.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/baclassic/_tournament.py b/dist/ba_data/python/baclassic/_tournament.py index e71d960..d1b7aea 100644 --- a/dist/ba_data/python/baclassic/_tournament.py +++ b/dist/ba_data/python/baclassic/_tournament.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from bacommon.bs import ClassicChestAppearance +from bacommon.classic import ClassicChestAppearance import babase import bauiv1 import bascenev1 diff --git a/dist/ba_data/python/baclassic/macmusicapp.py b/dist/ba_data/python/baclassic/macmusicapp.py index 48a41e5..bcde38d 100644 --- a/dist/ba_data/python/baclassic/macmusicapp.py +++ b/dist/ba_data/python/baclassic/macmusicapp.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Music playback functionality using the Mac Music (formerly iTunes) app.""" + from __future__ import annotations import logging @@ -95,7 +96,7 @@ class _MacMusicAppThread(threading.Thread): def do_print() -> None: babase.apptimer( 0.5, - babase.Call( + babase.CallStrict( babase.screenmessage, babase.Lstr(resource='usingItunesText'), (0, 1, 0), @@ -198,7 +199,9 @@ class _MacMusicAppThread(threading.Thread): except Exception as exc: print('Error getting iTunes playlists:', exc) playlists = [] - babase.pushcall(babase.Call(target, playlists), from_other_thread=True) + babase.pushcall( + babase.CallStrict(target, playlists), from_other_thread=True + ) def _handle_play_command(self, target: str | None) -> None: if target is None: @@ -246,7 +249,7 @@ class _MacMusicAppThread(threading.Thread): pass else: babase.pushcall( - babase.Call( + babase.CallStrict( babase.screenmessage, babase.app.lang.get_resource('playlistNotFoundText') + ': \'' diff --git a/dist/ba_data/python/baclassic/osmusic.py b/dist/ba_data/python/baclassic/osmusic.py index 8a79bf7..c48b2fc 100644 --- a/dist/ba_data/python/baclassic/osmusic.py +++ b/dist/ba_data/python/baclassic/osmusic.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Music playback using OS functionality exposed through the C++ layer.""" + from __future__ import annotations import os @@ -151,7 +152,7 @@ class _PickFolderSongThread(threading.Thread): ).evaluate() ) babase.pushcall( - babase.Call(self._callback, all_files, None), + babase.CallStrict(self._callback, all_files, None), from_other_thread=True, ) except Exception as exc: @@ -162,6 +163,6 @@ class _PickFolderSongThread(threading.Thread): except Exception: err_str = '' babase.pushcall( - babase.Call(self._callback, self._path, err_str), + babase.CallStrict(self._callback, self._path, err_str), from_other_thread=True, ) diff --git a/dist/ba_data/python/bacommon/analytics.py b/dist/ba_data/python/bacommon/analytics.py new file mode 100644 index 0000000..fef2a9d --- /dev/null +++ b/dist/ba_data/python/bacommon/analytics.py @@ -0,0 +1,75 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Analytics support.""" + +from __future__ import annotations + +from typing import assert_never, override, Annotated + +from enum import Enum, unique +from dataclasses import dataclass + +from efro.dataclassio import ioprepped, IOMultiType, IOAttrs + + +class AnalyticsEventTypeID(Enum): + """Type ID for each of our subclasses.""" + + CLASSIC = 'c' + + +class AnalyticsEvent(IOMultiType[AnalyticsEventTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> AnalyticsEventTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: AnalyticsEventTypeID) -> type[AnalyticsEvent]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = AnalyticsEventTypeID + if type_id is t.CLASSIC: + return ClassicAnalyticsEvent + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + +@ioprepped +@dataclass +class ClassicAnalyticsEvent(AnalyticsEvent): + """Analytics event related to classic.""" + + @unique + class EventType(Enum): + """Types of classic events.""" + + JOIN_PUBLIC_PARTY = 'jpb' + JOIN_PRIVATE_PARTY = 'jpr' + JOIN_PARTY_BY_ADDRESS = 'ja' + JOIN_NEARBY_PARTY = 'jn' + START_TEAMS_SESSION = 'st' + START_FFA_SESSION = 'sf' + START_COOP_SESSION = 'sc' + START_TOURNEY_COOP_SESSION = 'stc' + + eventtype: Annotated[EventType, IOAttrs('t')] + extra: Annotated[str | None, IOAttrs('e', store_default=False)] = None + + @override + @classmethod + def get_type_id(cls) -> AnalyticsEventTypeID: + return AnalyticsEventTypeID.CLASSIC diff --git a/dist/ba_data/python/bacommon/assets.py b/dist/ba_data/python/bacommon/assets.py index 159307d..160a9e5 100644 --- a/dist/ba_data/python/bacommon/assets.py +++ b/dist/ba_data/python/bacommon/assets.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to cloud based assets.""" +"""Functionality related to cloud based assets. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations diff --git a/dist/ba_data/python/bacommon/bacloud.py b/dist/ba_data/python/bacommon/bacloud.py index 0ea6e86..37f1450 100644 --- a/dist/ba_data/python/bacommon/bacloud.py +++ b/dist/ba_data/python/bacommon/bacloud.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to the bacloud tool.""" +"""Functionality related to the bacloud tool. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations @@ -14,7 +20,7 @@ if TYPE_CHECKING: # Version is sent to the master-server with all commands. Can be incremented # if we need to change behavior server-side to go along with client changes. -BACLOUD_VERSION = 13 +BACLOUD_VERSION = 14 def asset_file_cache_path(filehash: str) -> str: @@ -91,12 +97,30 @@ class ResponseData: #: response processing (including error handling) occurs. message: Annotated[str | None, IOAttrs('m', store_default=False)] = None - #: End arg for message print() call. + #: Value for the 'end' arg of the message print() call. message_end: Annotated[str, IOAttrs('m_end', store_default=False)] = '\n' - #: If present, client should abort with this error message. + #: If present, client should print this message before any other + #: response processing (including error handling) occurs. + message_stderr: Annotated[ + str | None, IOAttrs('m2', store_default=False) + ] = None + + #: Value for the 'end' arg of the message print() call. + message_stderr_end: Annotated[ + str, IOAttrs('m2_end', store_default=False) + ] = '\n' + + #: If present, client should abort with this error message and + #: return-code 2. error: Annotated[str | None, IOAttrs('e', store_default=False)] = None + #: If present for an interactive command, specifies the return code + #: for the process. Note that this only applies if error is not set. + #: Standard return codes are 0 for success, 1 for a successful run + #: but negative result, and 2 for errors. + return_code: Annotated[int | None, IOAttrs('r', store_default=False)] = None + #: How long to wait before proceeding with remaining response (can #: be useful when waiting for server progress in a loop). delay_seconds: Annotated[float, IOAttrs('d', store_default=False)] = 0.0 @@ -170,6 +194,17 @@ class ResponseData: #: End arg for end_message print() call. end_message_end: Annotated[str, IOAttrs('eme', store_default=False)] = '\n' + #: If present, a message that should be printed after all other + #: response processing is done. + end_message_stderr: Annotated[ + str | None, IOAttrs('em2', store_default=False) + ] = None + + #: End arg for end_message print() call. + end_message_stderr_end: Annotated[ + str, IOAttrs('em2e', store_default=False) + ] = '\n' + #: If present, this command is run with these args at the end of #: response processing. end_command: Annotated[ diff --git a/dist/ba_data/python/bacommon/bs.py b/dist/ba_data/python/bacommon/bs.py deleted file mode 100644 index 0bba7bf..0000000 --- a/dist/ba_data/python/bacommon/bs.py +++ /dev/null @@ -1,1062 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -# pylint: disable=too-many-lines -"""BombSquad specific bits.""" - -from __future__ import annotations - -import datetime -from enum import Enum -from dataclasses import dataclass, field -from typing import Annotated, override, assert_never - -from efro.util import pairs_to_flat -from efro.dataclassio import ioprepped, IOAttrs, IOMultiType -from efro.message import Message, Response - -# Token counts for our various packs. -TOKENS1_COUNT = 50 -TOKENS2_COUNT = 500 -TOKENS3_COUNT = 1200 -TOKENS4_COUNT = 2600 - - -@ioprepped -@dataclass -class LegacyRequest(Message): - """A generic request for the legacy master server.""" - - request: Annotated[str, IOAttrs('r')] - request_type: Annotated[str, IOAttrs('t')] - user_agent_string: Annotated[str, IOAttrs('u')] - data: Annotated[str, IOAttrs('d')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [LegacyResponse] - - -@ioprepped -@dataclass -class LegacyResponse(Response): - """Response for generic legacy request.""" - - data: Annotated[str | None, IOAttrs('d')] - zipped: Annotated[bool, IOAttrs('z')] - - -@ioprepped -@dataclass -class PrivatePartyMessage(Message): - """Message asking about info we need for private-party UI.""" - - need_datacode: Annotated[bool, IOAttrs('d')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [PrivatePartyResponse] - - -@ioprepped -@dataclass -class PrivatePartyResponse(Response): - """Here's that private party UI info you asked for, boss.""" - - success: Annotated[bool, IOAttrs('s')] - tokens: Annotated[int, IOAttrs('t')] - gold_pass: Annotated[bool, IOAttrs('g')] - datacode: Annotated[str | None, IOAttrs('d')] - - -@ioprepped -@dataclass -class GetClassicPurchasesMessage(Message): - """Asking for current account's classic purchases.""" - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [GetClassicPurchasesResponse] - - -@ioprepped -@dataclass -class GetClassicPurchasesResponse(Response): - """Here's those classic purchases ya asked for boss.""" - - purchases: Annotated[set[str], IOAttrs('p')] - - -class ClassicChestAppearance(Enum): - """Appearances bombsquad classic chests can have.""" - - UNKNOWN = 'u' - DEFAULT = 'd' - L1 = 'l1' - L2 = 'l2' - L3 = 'l3' - L4 = 'l4' - L5 = 'l5' - L6 = 'l6' - - @property - def pretty_name(self) -> str: - """Pretty name for the chest in English.""" - # pylint: disable=too-many-return-statements - cls = type(self) - - if self is cls.UNKNOWN: - return 'Unknown Chest' - if self is cls.DEFAULT: - return 'Chest' - if self is cls.L1: - return 'L1 Chest' - if self is cls.L2: - return 'L2 Chest' - if self is cls.L3: - return 'L3 Chest' - if self is cls.L4: - return 'L4 Chest' - if self is cls.L5: - return 'L5 Chest' - if self is cls.L6: - return 'L6 Chest' - - assert_never(self) - - -@ioprepped -@dataclass -class ClassicAccountLiveData: - """Live account data fed to the client in the bs classic app mode.""" - - @dataclass - class Chest: - """A lovely chest.""" - - appearance: Annotated[ - ClassicChestAppearance, - IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), - ] - create_time: Annotated[datetime.datetime, IOAttrs('c')] - unlock_time: Annotated[datetime.datetime, IOAttrs('t')] - unlock_tokens: Annotated[int, IOAttrs('k')] - ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')] - - class LeagueType(Enum): - """Type of league we are in.""" - - BRONZE = 'b' - SILVER = 's' - GOLD = 'g' - DIAMOND = 'd' - - class Flag(Enum): - """Flags set for our account.""" - - ASK_FOR_REVIEW = 'r' - - tickets: Annotated[int, IOAttrs('ti')] - - tokens: Annotated[int, IOAttrs('to')] - gold_pass: Annotated[bool, IOAttrs('g')] - remove_ads: Annotated[bool, IOAttrs('r')] - - achievements: Annotated[int, IOAttrs('a')] - achievements_total: Annotated[int, IOAttrs('at')] - - league_type: Annotated[LeagueType | None, IOAttrs('lt')] - league_num: Annotated[int | None, IOAttrs('ln')] - league_rank: Annotated[int | None, IOAttrs('lr')] - - level: Annotated[int, IOAttrs('lv')] - xp: Annotated[int, IOAttrs('xp')] - xpmax: Annotated[int, IOAttrs('xpm')] - - inbox_count: Annotated[int, IOAttrs('ibc')] - inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')] - inbox_contains_prize: Annotated[bool, IOAttrs('icp')] - - chests: Annotated[dict[str, Chest], IOAttrs('c')] - - # State id of our purchases for builds 22459+. - purchases_state: Annotated[str | None, IOAttrs('p')] - - flags: Annotated[set[Flag], IOAttrs('f', soft_default_factory=set)] - - -class DisplayItemTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - TICKETS = 't' - TOKENS = 'k' - TEST = 's' - CHEST = 'c' - - -class DisplayItem(IOMultiType[DisplayItemTypeID]): - """Some amount of something that can be shown or described. - - Used to depict chest contents or other rewards or prices. - """ - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: DisplayItemTypeID) -> type[DisplayItem]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - - t = DisplayItemTypeID - if type_id is t.UNKNOWN: - return UnknownDisplayItem - if type_id is t.TICKETS: - return TicketsDisplayItem - if type_id is t.TOKENS: - return TokensDisplayItem - if type_id is t.TEST: - return TestDisplayItem - if type_id is t.CHEST: - return ChestDisplayItem - - # Important to make sure we provide all types. - assert_never(type_id) - - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - """Return a string description and subs for the item. - - These decriptions are baked into the DisplayItemWrapper and - should be accessed from there when available. This allows - clients to give descriptions even for newer display items they - don't recognize. - """ - raise NotImplementedError() - - # Implement fallbacks so client can digest item lists even if they - # contain unrecognized stuff. DisplayItemWrapper contains basic - # baked down info that they can still use in such cases. - @override - @classmethod - def get_unknown_type_fallback(cls) -> DisplayItem: - return UnknownDisplayItem() - - -@ioprepped -@dataclass -class UnknownDisplayItem(DisplayItem): - """Something we don't know how to display.""" - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.UNKNOWN - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - import logging - - # Make noise but don't break. - logging.exception( - 'UnknownDisplayItem.get_description() should never be called.' - ' Always access descriptions on the DisplayItemWrapper.' - ) - return 'Unknown', [] - - -@ioprepped -@dataclass -class TicketsDisplayItem(DisplayItem): - """Some amount of tickets.""" - - count: Annotated[int, IOAttrs('c')] - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TICKETS - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return '${C} Tickets', [('${C}', str(self.count))] - - -@ioprepped -@dataclass -class TokensDisplayItem(DisplayItem): - """Some amount of tokens.""" - - count: Annotated[int, IOAttrs('c')] - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TOKENS - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return '${C} Tokens', [('${C}', str(self.count))] - - -@ioprepped -@dataclass -class TestDisplayItem(DisplayItem): - """Fills usable space for a display-item - good for calibration.""" - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TEST - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return 'Test Display Item Here', [] - - -@ioprepped -@dataclass -class ChestDisplayItem(DisplayItem): - """Display a chest.""" - - appearance: Annotated[ClassicChestAppearance, IOAttrs('a')] - - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.CHEST - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return self.appearance.pretty_name, [] - - -@ioprepped -@dataclass -class DisplayItemWrapper: - """Wraps a DisplayItem and common info.""" - - item: Annotated[DisplayItem, IOAttrs('i')] - description: Annotated[str, IOAttrs('d')] - description_subs: Annotated[list[str] | None, IOAttrs('s')] - - @classmethod - def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper: - """Convenience method to wrap a DisplayItem.""" - desc, subs = item.get_description() - return DisplayItemWrapper(item, desc, pairs_to_flat(subs)) - - -@ioprepped -@dataclass -class ChestInfoMessage(Message): - """Request info about a chest.""" - - chest_id: Annotated[str, IOAttrs('i')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [ChestInfoResponse] - - -@ioprepped -@dataclass -class ChestInfoResponse(Response): - """Here's that chest info you asked for, boss.""" - - @dataclass - class Chest: - """A lovely chest.""" - - @dataclass - class PrizeSet: - """A possible set of prizes for this chest.""" - - weight: Annotated[float, IOAttrs('w')] - contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')] - - appearance: Annotated[ - ClassicChestAppearance, - IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), - ] - - # How much it costs to unlock *now*. - unlock_tokens: Annotated[int, IOAttrs('tk')] - - # When it unlocks on its own. - unlock_time: Annotated[datetime.datetime, IOAttrs('t')] - - # Possible prizes we contain. - prizesets: Annotated[list[PrizeSet], IOAttrs('p')] - - # Are ads allowed now? - ad_allow: Annotated[bool, IOAttrs('aa')] - - chest: Annotated[Chest | None, IOAttrs('c')] - user_tokens: Annotated[int | None, IOAttrs('t')] - - -class ClientUITypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - BASIC = 'b' - - -class ClientUI(IOMultiType[ClientUITypeID]): - """Defines some user interface on the client.""" - - @override - @classmethod - def get_type_id(cls) -> ClientUITypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: ClientUITypeID) -> type[ClientUI]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - out: type[ClientUI] - - t = ClientUITypeID - if type_id is t.UNKNOWN: - out = UnknownClientUI - elif type_id is t.BASIC: - out = BasicClientUI - else: - # Important to make sure we provide all types. - assert_never(type_id) - return out - - @override - @classmethod - def get_unknown_type_fallback(cls) -> ClientUI: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return UnknownClientUI() - - -@ioprepped -@dataclass -class UnknownClientUI(ClientUI): - """Fallback type for unrecognized entries.""" - - @override - @classmethod - def get_type_id(cls) -> ClientUITypeID: - return ClientUITypeID.UNKNOWN - - -class BasicClientUIComponentTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - TEXT = 't' - LINK = 'l' - BS_CLASSIC_TOURNEY_RESULT = 'ct' - DISPLAY_ITEMS = 'di' - EXPIRE_TIME = 'd' - - -class BasicClientUIComponent(IOMultiType[BasicClientUIComponentTypeID]): - """Top level class for our multitype.""" - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type( - cls, type_id: BasicClientUIComponentTypeID - ) -> type[BasicClientUIComponent]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - - t = BasicClientUIComponentTypeID - if type_id is t.UNKNOWN: - return BasicClientUIComponentUnknown - if type_id is t.TEXT: - return BasicClientUIComponentText - if type_id is t.LINK: - return BasicClientUIComponentLink - if type_id is t.BS_CLASSIC_TOURNEY_RESULT: - return BasicClientUIBsClassicTourneyResult - if type_id is t.DISPLAY_ITEMS: - return BasicClientUIDisplayItems - if type_id is t.EXPIRE_TIME: - return BasicClientUIExpireTime - - # Important to make sure we provide all types. - assert_never(type_id) - - @override - @classmethod - def get_unknown_type_fallback(cls) -> BasicClientUIComponent: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return BasicClientUIComponentUnknown() - - -@ioprepped -@dataclass -class BasicClientUIComponentUnknown(BasicClientUIComponent): - """An unknown basic client component type. - - In practice these should never show up since the master-server - generates these on the fly for the client and so should not send - clients one they can't digest. - """ - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.UNKNOWN - - -@ioprepped -@dataclass -class BasicClientUIComponentText(BasicClientUIComponent): - """Show some text in the inbox message.""" - - text: Annotated[str, IOAttrs('t')] - subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( - default_factory=list - ) - scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 - color: Annotated[ - tuple[float, float, float, float], IOAttrs('c', store_default=False) - ] = (1.0, 1.0, 1.0, 1.0) - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.TEXT - - -@ioprepped -@dataclass -class BasicClientUIComponentLink(BasicClientUIComponent): - """Show a link in the inbox message.""" - - url: Annotated[str, IOAttrs('u')] - label: Annotated[str, IOAttrs('l')] - subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( - default_factory=list - ) - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.LINK - - -@ioprepped -@dataclass -class BasicClientUIBsClassicTourneyResult(BasicClientUIComponent): - """Show info about a classic tourney.""" - - tournament_id: Annotated[str, IOAttrs('t')] - game: Annotated[str, IOAttrs('g')] - players: Annotated[int, IOAttrs('p')] - rank: Annotated[int, IOAttrs('r')] - trophy: Annotated[str | None, IOAttrs('tr')] - prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')] - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.BS_CLASSIC_TOURNEY_RESULT - - -@ioprepped -@dataclass -class BasicClientUIDisplayItems(BasicClientUIComponent): - """Show some display-items.""" - - items: Annotated[list[DisplayItemWrapper], IOAttrs('d')] - width: Annotated[float, IOAttrs('w')] = 100.0 - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.DISPLAY_ITEMS - - -@ioprepped -@dataclass -class BasicClientUIExpireTime(BasicClientUIComponent): - """Show expire-time.""" - - time: Annotated[datetime.datetime, IOAttrs('d')] - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicClientUIComponentTypeID: - return BasicClientUIComponentTypeID.EXPIRE_TIME - - -@ioprepped -@dataclass -class BasicClientUI(ClientUI): - """A basic UI for the client.""" - - class ButtonLabel(Enum): - """Distinct button labels we support.""" - - UNKNOWN = 'u' - OK = 'o' - APPLY = 'a' - CANCEL = 'c' - ACCEPT = 'ac' - DECLINE = 'dn' - IGNORE = 'ig' - CLAIM = 'cl' - DISCARD = 'd' - - class InteractionStyle(Enum): - """Overall interaction styles we support.""" - - UNKNOWN = 'u' - BUTTON_POSITIVE = 'p' - BUTTON_POSITIVE_NEGATIVE = 'pn' - - components: Annotated[list[BasicClientUIComponent], IOAttrs('s')] - - interaction_style: Annotated[ - InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN) - ] = InteractionStyle.BUTTON_POSITIVE - - button_label_positive: Annotated[ - ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN) - ] = ButtonLabel.OK - - button_label_negative: Annotated[ - ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN) - ] = ButtonLabel.CANCEL - - @override - @classmethod - def get_type_id(cls) -> ClientUITypeID: - return ClientUITypeID.BASIC - - def contains_unknown_elements(self) -> bool: - """Whether something within us is an unknown type or enum.""" - return ( - self.interaction_style is self.InteractionStyle.UNKNOWN - or self.button_label_positive is self.ButtonLabel.UNKNOWN - or self.button_label_negative is self.ButtonLabel.UNKNOWN - or any( - c.get_type_id() is BasicClientUIComponentTypeID.UNKNOWN - for c in self.components - ) - ) - - -@ioprepped -@dataclass -class ClientUIWrapper: - """Wrapper for a ClientUI and its common data.""" - - id: Annotated[str, IOAttrs('i')] - createtime: Annotated[datetime.datetime, IOAttrs('c')] - ui: Annotated[ClientUI, IOAttrs('e')] - - -@ioprepped -@dataclass -class InboxRequestMessage(Message): - """Message requesting our inbox.""" - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [InboxRequestResponse] - - -@ioprepped -@dataclass -class InboxRequestResponse(Response): - """Here's that inbox contents you asked for, boss.""" - - wrappers: Annotated[list[ClientUIWrapper], IOAttrs('w')] - - # Printable error if something goes wrong. - error: Annotated[str | None, IOAttrs('e')] = None - - -class ClientUIAction(Enum): - """Types of actions we can run.""" - - BUTTON_PRESS_POSITIVE = 'p' - BUTTON_PRESS_NEGATIVE = 'n' - - -class ClientEffectTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - SCREEN_MESSAGE = 'm' - SOUND = 's' - DELAY = 'd' - CHEST_WAIT_TIME_ANIMATION = 't' - TICKETS_ANIMATION = 'ta' - TOKENS_ANIMATION = 'toa' - - -class ClientEffect(IOMultiType[ClientEffectTypeID]): - """Something that can happen on the client. - - This can include screen messages, sounds, visual effects, etc. - """ - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - # pylint: disable=too-many-return-statements - - t = ClientEffectTypeID - if type_id is t.UNKNOWN: - return ClientEffectUnknown - if type_id is t.SCREEN_MESSAGE: - return ClientEffectScreenMessage - if type_id is t.SOUND: - return ClientEffectSound - if type_id is t.DELAY: - return ClientEffectDelay - if type_id is t.CHEST_WAIT_TIME_ANIMATION: - return ClientEffectChestWaitTimeAnimation - if type_id is t.TICKETS_ANIMATION: - return ClientEffectTicketsAnimation - if type_id is t.TOKENS_ANIMATION: - return ClientEffectTokensAnimation - - # Important to make sure we provide all types. - assert_never(type_id) - - @override - @classmethod - def get_unknown_type_fallback(cls) -> ClientEffect: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return ClientEffectUnknown() - - -@ioprepped -@dataclass -class ClientEffectUnknown(ClientEffect): - """Fallback substitute for types we don't recognize.""" - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.UNKNOWN - - -@ioprepped -@dataclass -class ClientEffectScreenMessage(ClientEffect): - """Display a screen-message.""" - - message: Annotated[str, IOAttrs('m')] - subs: Annotated[list[str], IOAttrs('s')] = field(default_factory=list) - color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0) - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.SCREEN_MESSAGE - - -@ioprepped -@dataclass -class ClientEffectSound(ClientEffect): - """Play a sound.""" - - class Sound(Enum): - """Sounds that can be made alongside the message.""" - - UNKNOWN = 'u' - CASH_REGISTER = 'c' - ERROR = 'e' - POWER_DOWN = 'p' - GUN_COCKING = 'g' - - sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)] - volume: Annotated[float, IOAttrs('v')] = 1.0 - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.SOUND - - -@ioprepped -@dataclass -class ClientEffectChestWaitTimeAnimation(ClientEffect): - """Animate chest wait time changing.""" - - chestid: Annotated[str, IOAttrs('c')] - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[datetime.datetime, IOAttrs('o')] - endvalue: Annotated[datetime.datetime, IOAttrs('n')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectTicketsAnimation(ClientEffect): - """Animate tickets count.""" - - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[int, IOAttrs('s')] - endvalue: Annotated[int, IOAttrs('e')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.TICKETS_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectTokensAnimation(ClientEffect): - """Animate tokens count.""" - - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[int, IOAttrs('s')] - endvalue: Annotated[int, IOAttrs('e')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.TOKENS_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectDelay(ClientEffect): - """Delay effect processing.""" - - seconds: Annotated[float, IOAttrs('s')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.DELAY - - -@ioprepped -@dataclass -class ClientUIActionMessage(Message): - """Do something to a client ui.""" - - id: Annotated[str, IOAttrs('i')] - action: Annotated[ClientUIAction, IOAttrs('a')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [ClientUIActionResponse] - - -@ioprepped -@dataclass -class ClientUIActionResponse(Response): - """Did something to that inbox entry, boss.""" - - class ErrorType(Enum): - """Types of errors that may have occurred.""" - - # Probably a future error type we don't recognize. - UNKNOWN = 'u' - - # Something went wrong on the server, but specifics are not - # relevant. - INTERNAL = 'i' - - # The entry expired on the server. In various cases such as 'ok' - # buttons this can generally be ignored. - EXPIRED = 'e' - - error_type: Annotated[ - ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN) - ] - - # User facing error message in the case of errors. - error_message: Annotated[str | None, IOAttrs('em')] - - effects: Annotated[list[ClientEffect], IOAttrs('fx')] - - -@ioprepped -@dataclass -class ScoreSubmitMessage(Message): - """Let the server know we got some score in something.""" - - score_token: Annotated[str, IOAttrs('t')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [ScoreSubmitResponse] - - -@ioprepped -@dataclass -class ScoreSubmitResponse(Response): - """Did something to that inbox entry, boss.""" - - # Things we should show on our end. - effects: Annotated[list[ClientEffect], IOAttrs('fx')] - - -@ioprepped -@dataclass -class ChestActionMessage(Message): - """Request action about a chest.""" - - class Action(Enum): - """Types of actions we can request.""" - - # Unlocking (for free or with tokens). - UNLOCK = 'u' - - # Watched an ad to reduce wait. - AD = 'ad' - - action: Annotated[Action, IOAttrs('a')] - - # Tokens we are paying (only applies to unlock). - token_payment: Annotated[int, IOAttrs('t')] - - chest_id: Annotated[str, IOAttrs('i')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [ChestActionResponse] - - -@ioprepped -@dataclass -class ChestActionResponse(Response): - """Here's the results of that action you asked for, boss.""" - - # Tokens that were actually charged. - tokens_charged: Annotated[int, IOAttrs('t')] = 0 - - # If present, signifies the chest has been opened and we should show - # the user this stuff that was in it. - contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None - - # If contents are present, which of the chest's prize-sets they - # represent. - prizeindex: Annotated[int, IOAttrs('i')] = 0 - - # Printable error if something goes wrong. - error: Annotated[str | None, IOAttrs('e')] = None - - # Printable warning. Shown in orange with an error sound. Does not - # mean the action failed; only that there's something to tell the - # users such as 'It looks like you are faking ad views; stop it or - # you won't have ad options anymore.' - warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None - - # Printable success message. Shown in green with a cash-register - # sound. Can be used for things like successful wait reductions via - # ad views. Used in builds earlier than 22311; can remove once - # 22311+ is ubiquitous. - success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None - - # Effects to show on the client. Replaces warning and success_msg in - # build 22311 or newer. - effects: Annotated[ - list[ClientEffect], IOAttrs('fx', store_default=False) - ] = field(default_factory=list) - - -@ioprepped -@dataclass -class GlobalProfileCheckMessage(Message): - """Is this global profile name available?""" - - name: Annotated[str, IOAttrs('n')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [GlobalProfileCheckResponse] - - -@ioprepped -@dataclass -class GlobalProfileCheckResponse(Response): - """Here's that profile check ya asked for boss.""" - - available: Annotated[bool, IOAttrs('a')] - ticket_cost: Annotated[int, IOAttrs('tc')] - - -@ioprepped -@dataclass -class SendInfoMessage(Message): - """User is using the send-info function.""" - - description: Annotated[str, IOAttrs('c')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [SendInfoResponse] - - -@ioprepped -@dataclass -class SendInfoResponse(Response): - """Response to sending info to the server.""" - - handled: Annotated[bool, IOAttrs('v')] - message: Annotated[str | None, IOAttrs('m', store_default=False)] = None - effects: Annotated[ - list[ClientEffect], IOAttrs('e', store_default=False) - ] = field(default_factory=list) - legacy_code: Annotated[str | None, IOAttrs('l', store_default=False)] = None diff --git a/dist/ba_data/python/bacommon/bs/__init__.py b/dist/ba_data/python/bacommon/bs/__init__.py deleted file mode 100644 index 7f26e38..0000000 --- a/dist/ba_data/python/bacommon/bs/__init__.py +++ /dev/null @@ -1,139 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Functionality related to bombsquad classic.""" - -from bacommon.bs._account import ( - ClassicAccountLiveData, -) -from bacommon.bs._bs import ( - TOKENS1_COUNT, - TOKENS2_COUNT, - TOKENS3_COUNT, - TOKENS4_COUNT, -) -from bacommon.bs._chest import ( - ClassicChestAppearance, -) -from bacommon.bs._clienteffect import ( - ClientEffect, - ClientEffectChestWaitTimeAnimation, - ClientEffectDelay, - ClientEffectScreenMessage, - ClientEffectSound, - ClientEffectTicketsAnimation, - ClientEffectTokensAnimation, - ClientEffectTypeID, - ClientEffectUnknown, -) -from bacommon.bs._clouddialog import ( - BasicCloudDialog, - BasicCloudDialogComponent, - BasicCloudDialogBsClassicTourneyResult, - BasicCloudDialogComponentLink, - BasicCloudDialogComponentText, - BasicCloudDialogComponentTypeID, - BasicCloudDialogComponentUnknown, - BasicCloudDialogDisplayItems, - BasicCloudDialogExpireTime, - CloudDialog, - CloudDialogAction, - CloudDialogTypeID, - CloudDialogWrapper, - UnknownCloudDialog, -) -from bacommon.bs._cloudui import CloudUITypeID, CloudUI -from bacommon.bs._displayitem import ( - ChestDisplayItem, - DisplayItem, - DisplayItemTypeID, - DisplayItemWrapper, - TestDisplayItem, - TicketsDisplayItem, - TokensDisplayItem, - UnknownDisplayItem, -) -from bacommon.bs._msg import ( - ChestActionMessage, - ChestActionResponse, - ChestInfoMessage, - ChestInfoResponse, - CloudDialogActionMessage, - CloudDialogActionResponse, - GetClassicPurchasesMessage, - GetClassicPurchasesResponse, - GlobalProfileCheckMessage, - GlobalProfileCheckResponse, - InboxRequestMessage, - InboxRequestResponse, - LegacyRequest, - LegacyResponse, - PrivatePartyMessage, - PrivatePartyResponse, - ScoreSubmitMessage, - ScoreSubmitResponse, - SendInfoMessage, - SendInfoResponse, -) - - -__all__ = [ - 'BasicCloudDialog', - 'BasicCloudDialogComponent', - 'BasicCloudDialogBsClassicTourneyResult', - 'BasicCloudDialogComponentLink', - 'BasicCloudDialogComponentText', - 'BasicCloudDialogComponentTypeID', - 'BasicCloudDialogComponentUnknown', - 'BasicCloudDialogDisplayItems', - 'BasicCloudDialogExpireTime', - 'ChestActionMessage', - 'ChestActionResponse', - 'ChestDisplayItem', - 'ChestInfoMessage', - 'ChestInfoResponse', - 'ClassicAccountLiveData', - 'ClassicChestAppearance', - 'ClientEffect', - 'ClientEffectChestWaitTimeAnimation', - 'ClientEffectDelay', - 'ClientEffectScreenMessage', - 'ClientEffectSound', - 'ClientEffectTicketsAnimation', - 'ClientEffectTokensAnimation', - 'ClientEffectTypeID', - 'ClientEffectUnknown', - 'CloudDialog', - 'CloudDialogAction', - 'CloudDialogActionMessage', - 'CloudDialogActionResponse', - 'CloudDialogTypeID', - 'CloudDialogWrapper', - 'CloudUI', - 'CloudUITypeID', - 'DisplayItem', - 'DisplayItemTypeID', - 'DisplayItemWrapper', - 'GetClassicPurchasesMessage', - 'GetClassicPurchasesResponse', - 'GlobalProfileCheckMessage', - 'GlobalProfileCheckResponse', - 'InboxRequestMessage', - 'InboxRequestResponse', - 'LegacyRequest', - 'LegacyResponse', - 'PrivatePartyMessage', - 'PrivatePartyResponse', - 'ScoreSubmitMessage', - 'ScoreSubmitResponse', - 'SendInfoMessage', - 'SendInfoResponse', - 'TestDisplayItem', - 'TicketsDisplayItem', - 'TOKENS1_COUNT', - 'TOKENS2_COUNT', - 'TOKENS3_COUNT', - 'TOKENS4_COUNT', - 'TokensDisplayItem', - 'UnknownCloudDialog', - 'UnknownDisplayItem', -] diff --git a/dist/ba_data/python/bacommon/bs/_clienteffect.py b/dist/ba_data/python/bacommon/bs/_clienteffect.py deleted file mode 100644 index c7f7f70..0000000 --- a/dist/ba_data/python/bacommon/bs/_clienteffect.py +++ /dev/null @@ -1,180 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""ClientEffect related functionality.""" - -from __future__ import annotations - -import datetime -from enum import Enum -from dataclasses import dataclass, field -from typing import Annotated, override, assert_never - -from efro.dataclassio import ioprepped, IOAttrs, IOMultiType - - -class ClientEffectTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - SCREEN_MESSAGE = 'm' - SOUND = 's' - DELAY = 'd' - CHEST_WAIT_TIME_ANIMATION = 't' - TICKETS_ANIMATION = 'ta' - TOKENS_ANIMATION = 'toa' - - -class ClientEffect(IOMultiType[ClientEffectTypeID]): - """Something that can happen on the client. - - This can include screen messages, sounds, visual effects, etc. - """ - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - # pylint: disable=too-many-return-statements - - t = ClientEffectTypeID - if type_id is t.UNKNOWN: - return ClientEffectUnknown - if type_id is t.SCREEN_MESSAGE: - return ClientEffectScreenMessage - if type_id is t.SOUND: - return ClientEffectSound - if type_id is t.DELAY: - return ClientEffectDelay - if type_id is t.CHEST_WAIT_TIME_ANIMATION: - return ClientEffectChestWaitTimeAnimation - if type_id is t.TICKETS_ANIMATION: - return ClientEffectTicketsAnimation - if type_id is t.TOKENS_ANIMATION: - return ClientEffectTokensAnimation - - # Important to make sure we provide all types. - assert_never(type_id) - - @override - @classmethod - def get_unknown_type_fallback(cls) -> ClientEffect: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return ClientEffectUnknown() - - -@ioprepped -@dataclass -class ClientEffectUnknown(ClientEffect): - """Fallback substitute for types we don't recognize.""" - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.UNKNOWN - - -@ioprepped -@dataclass -class ClientEffectScreenMessage(ClientEffect): - """Display a screen-message.""" - - message: Annotated[str, IOAttrs('m')] - subs: Annotated[list[str], IOAttrs('s')] = field(default_factory=list) - color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0) - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.SCREEN_MESSAGE - - -@ioprepped -@dataclass -class ClientEffectSound(ClientEffect): - """Play a sound.""" - - class Sound(Enum): - """Sounds that can be made alongside the message.""" - - UNKNOWN = 'u' - CASH_REGISTER = 'c' - ERROR = 'e' - POWER_DOWN = 'p' - GUN_COCKING = 'g' - - sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)] - volume: Annotated[float, IOAttrs('v')] = 1.0 - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.SOUND - - -@ioprepped -@dataclass -class ClientEffectChestWaitTimeAnimation(ClientEffect): - """Animate chest wait time changing.""" - - chestid: Annotated[str, IOAttrs('c')] - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[datetime.datetime, IOAttrs('o')] - endvalue: Annotated[datetime.datetime, IOAttrs('n')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectTicketsAnimation(ClientEffect): - """Animate tickets count.""" - - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[int, IOAttrs('s')] - endvalue: Annotated[int, IOAttrs('e')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.TICKETS_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectTokensAnimation(ClientEffect): - """Animate tokens count.""" - - duration: Annotated[float, IOAttrs('u')] - startvalue: Annotated[int, IOAttrs('s')] - endvalue: Annotated[int, IOAttrs('e')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.TOKENS_ANIMATION - - -@ioprepped -@dataclass -class ClientEffectDelay(ClientEffect): - """Delay effect processing.""" - - seconds: Annotated[float, IOAttrs('s')] - - @override - @classmethod - def get_type_id(cls) -> ClientEffectTypeID: - return ClientEffectTypeID.DELAY diff --git a/dist/ba_data/python/bacommon/bs/_clouddialog.py b/dist/ba_data/python/bacommon/bs/_clouddialog.py deleted file mode 100644 index 4b4288f..0000000 --- a/dist/ba_data/python/bacommon/bs/_clouddialog.py +++ /dev/null @@ -1,308 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Simple cloud-defined UIs for things like notifications.""" - -from __future__ import annotations - -import datetime -from enum import Enum -from dataclasses import dataclass, field -from typing import Annotated, override, assert_never - -from efro.dataclassio import ioprepped, IOAttrs, IOMultiType - -from bacommon.bs._displayitem import DisplayItemWrapper - - -class CloudDialogTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - BASIC = 'b' - - -class CloudDialog(IOMultiType[CloudDialogTypeID]): - """Small self-contained ui bit provided by the cloud. - - These take care of updating and/or dismissing themselves based on - user input. Useful for things such as inbox messages. For more - complex UI construction, look at :class:`CloudUI`. - """ - - @override - @classmethod - def get_type_id(cls) -> CloudDialogTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: CloudDialogTypeID) -> type[CloudDialog]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - out: type[CloudDialog] - - t = CloudDialogTypeID - if type_id is t.UNKNOWN: - out = UnknownCloudDialog - elif type_id is t.BASIC: - out = BasicCloudDialog - else: - # Important to make sure we provide all types. - assert_never(type_id) - return out - - @override - @classmethod - def get_unknown_type_fallback(cls) -> CloudDialog: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return UnknownCloudDialog() - - -@ioprepped -@dataclass -class UnknownCloudDialog(CloudDialog): - """Fallback type for unrecognized entries.""" - - @override - @classmethod - def get_type_id(cls) -> CloudDialogTypeID: - return CloudDialogTypeID.UNKNOWN - - -class BasicCloudDialogComponentTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - TEXT = 't' - LINK = 'l' - BS_CLASSIC_TOURNEY_RESULT = 'ct' - DISPLAY_ITEMS = 'di' - EXPIRE_TIME = 'd' - - -class BasicCloudDialogComponent(IOMultiType[BasicCloudDialogComponentTypeID]): - """Top level class for our multitype.""" - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type( - cls, type_id: BasicCloudDialogComponentTypeID - ) -> type[BasicCloudDialogComponent]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - - t = BasicCloudDialogComponentTypeID - if type_id is t.UNKNOWN: - return BasicCloudDialogComponentUnknown - if type_id is t.TEXT: - return BasicCloudDialogComponentText - if type_id is t.LINK: - return BasicCloudDialogComponentLink - if type_id is t.BS_CLASSIC_TOURNEY_RESULT: - return BasicCloudDialogBsClassicTourneyResult - if type_id is t.DISPLAY_ITEMS: - return BasicCloudDialogDisplayItems - if type_id is t.EXPIRE_TIME: - return BasicCloudDialogExpireTime - - # Important to make sure we provide all types. - assert_never(type_id) - - @override - @classmethod - def get_unknown_type_fallback(cls) -> BasicCloudDialogComponent: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return BasicCloudDialogComponentUnknown() - - -@ioprepped -@dataclass -class BasicCloudDialogComponentUnknown(BasicCloudDialogComponent): - """An unknown basic client component type. - - In practice these should never show up since the master-server - generates these on the fly for the client and so should not send - clients one they can't digest. - """ - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.UNKNOWN - - -@ioprepped -@dataclass -class BasicCloudDialogComponentText(BasicCloudDialogComponent): - """Show some text in the inbox message.""" - - text: Annotated[str, IOAttrs('t')] - subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( - default_factory=list - ) - scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 - color: Annotated[ - tuple[float, float, float, float], IOAttrs('c', store_default=False) - ] = (1.0, 1.0, 1.0, 1.0) - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.TEXT - - -@ioprepped -@dataclass -class BasicCloudDialogComponentLink(BasicCloudDialogComponent): - """Show a link in the inbox message.""" - - url: Annotated[str, IOAttrs('u')] - label: Annotated[str, IOAttrs('l')] - subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( - default_factory=list - ) - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.LINK - - -@ioprepped -@dataclass -class BasicCloudDialogBsClassicTourneyResult(BasicCloudDialogComponent): - """Show info about a classic tourney.""" - - tournament_id: Annotated[str, IOAttrs('t')] - game: Annotated[str, IOAttrs('g')] - players: Annotated[int, IOAttrs('p')] - rank: Annotated[int, IOAttrs('r')] - trophy: Annotated[str | None, IOAttrs('tr')] - prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')] - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.BS_CLASSIC_TOURNEY_RESULT - - -@ioprepped -@dataclass -class BasicCloudDialogDisplayItems(BasicCloudDialogComponent): - """Show some display-items.""" - - items: Annotated[list[DisplayItemWrapper], IOAttrs('d')] - width: Annotated[float, IOAttrs('w')] = 100.0 - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.DISPLAY_ITEMS - - -@ioprepped -@dataclass -class BasicCloudDialogExpireTime(BasicCloudDialogComponent): - """Show expire-time.""" - - time: Annotated[datetime.datetime, IOAttrs('d')] - spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 - spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 - - @override - @classmethod - def get_type_id(cls) -> BasicCloudDialogComponentTypeID: - return BasicCloudDialogComponentTypeID.EXPIRE_TIME - - -@ioprepped -@dataclass -class BasicCloudDialog(CloudDialog): - """A basic UI for the client.""" - - class ButtonLabel(Enum): - """Distinct button labels we support.""" - - UNKNOWN = 'u' - OK = 'o' - APPLY = 'a' - CANCEL = 'c' - ACCEPT = 'ac' - DECLINE = 'dn' - IGNORE = 'ig' - CLAIM = 'cl' - DISCARD = 'd' - - class InteractionStyle(Enum): - """Overall interaction styles we support.""" - - UNKNOWN = 'u' - BUTTON_POSITIVE = 'p' - BUTTON_POSITIVE_NEGATIVE = 'pn' - - components: Annotated[list[BasicCloudDialogComponent], IOAttrs('s')] - - interaction_style: Annotated[ - InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN) - ] = InteractionStyle.BUTTON_POSITIVE - - button_label_positive: Annotated[ - ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN) - ] = ButtonLabel.OK - - button_label_negative: Annotated[ - ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN) - ] = ButtonLabel.CANCEL - - @override - @classmethod - def get_type_id(cls) -> CloudDialogTypeID: - return CloudDialogTypeID.BASIC - - def contains_unknown_elements(self) -> bool: - """Whether something within us is an unknown type or enum.""" - return ( - self.interaction_style is self.InteractionStyle.UNKNOWN - or self.button_label_positive is self.ButtonLabel.UNKNOWN - or self.button_label_negative is self.ButtonLabel.UNKNOWN - or any( - c.get_type_id() is BasicCloudDialogComponentTypeID.UNKNOWN - for c in self.components - ) - ) - - -@ioprepped -@dataclass -class CloudDialogWrapper: - """Wrapper for a CloudDialog and its common data.""" - - id: Annotated[str, IOAttrs('i')] - createtime: Annotated[datetime.datetime, IOAttrs('c')] - ui: Annotated[CloudDialog, IOAttrs('e')] - - -class CloudDialogAction(Enum): - """Types of actions we can run.""" - - BUTTON_PRESS_POSITIVE = 'p' - BUTTON_PRESS_NEGATIVE = 'n' diff --git a/dist/ba_data/python/bacommon/bs/_cloudui.py b/dist/ba_data/python/bacommon/bs/_cloudui.py deleted file mode 100644 index 1b4659c..0000000 --- a/dist/ba_data/python/bacommon/bs/_cloudui.py +++ /dev/null @@ -1,180 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Full UIs defined in the cloud - similar to a basic form of html""" - -from __future__ import annotations - -from enum import Enum -from dataclasses import dataclass -from typing import Annotated, override, assert_never - -from efro.dataclassio import ioprepped, IOAttrs, IOMultiType - - -class CloudUITypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - V1 = 'v1' - - -class CloudUI(IOMultiType[CloudUITypeID]): - """UI defined by the cloud. - - Conceptually similar to a basic html page, except using app UI. - """ - - @override - @classmethod - def get_type_id(cls) -> CloudUITypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type(cls, type_id: CloudUITypeID) -> type[CloudUI]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - out: type[CloudUI] - - t = CloudUITypeID - if type_id is t.UNKNOWN: - out = UnknownCloudUI - elif type_id is t.V1: - out = V1CloudUI - else: - # Important to make sure we provide all types. - assert_never(type_id) - return out - - @override - @classmethod - def get_unknown_type_fallback(cls) -> CloudUI: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return UnknownCloudUI() - - -@ioprepped -@dataclass -class UnknownCloudUI(CloudUI): - """Fallback type for unrecognized UI types. - - Will show the client a 'cannot display this UI' placeholder page. - """ - - @override - @classmethod - def get_type_id(cls) -> CloudUITypeID: - return CloudUITypeID.UNKNOWN - - -class V1CloudUIComponentTypeID(Enum): - """Type ID for each of our subclasses.""" - - UNKNOWN = 'u' - TEXT = 't' - - -class V1CloudUIComponent(IOMultiType[V1CloudUIComponentTypeID]): - """Top level class for our multitype.""" - - @override - @classmethod - def get_type_id(cls) -> V1CloudUIComponentTypeID: - # Require child classes to supply this themselves. If we did a - # full type registry/lookup here it would require us to import - # everything and would prevent lazy loading. - raise NotImplementedError() - - @override - @classmethod - def get_type( - cls, type_id: V1CloudUIComponentTypeID - ) -> type[V1CloudUIComponent]: - """Return the subclass for each of our type-ids.""" - # pylint: disable=cyclic-import - - t = V1CloudUIComponentTypeID - if type_id is t.UNKNOWN: - return V1CloudUIComponentUnknown - if type_id is t.TEXT: - return V1CloudUIComponentText - - # Important to make sure we provide all types. - assert_never(type_id) - - @override - @classmethod - def get_unknown_type_fallback(cls) -> V1CloudUIComponent: - # If we encounter some future message type we don't know - # anything about, drop in a placeholder. - return V1CloudUIComponentUnknown() - - -@ioprepped -@dataclass -class V1CloudUIComponentUnknown(V1CloudUIComponent): - """An unknown basic client component type. - - In practice these should never show up since the master-server - generates these on the fly for the client and so should not send - clients one they can't digest. - """ - - @override - @classmethod - def get_type_id(cls) -> V1CloudUIComponentTypeID: - return V1CloudUIComponentTypeID.UNKNOWN - - -@ioprepped -@dataclass -class V1CloudUIComponentText(V1CloudUIComponent): - """Show some text over a button.""" - - text: Annotated[str, IOAttrs('t')] - # position: Annotated[float, IOAttrs('p', store_default=False)] = 1.0 - # scale: Annotated[float, IOAttrs('s', store_default=False)] = 1.0 - # color: Annotated[ - # tuple[float, float, float, float], IOAttrs('c', store_default=False) - # ] = (1.0, 1.0, 1.0, 1.0) - - @override - @classmethod - def get_type_id(cls) -> V1CloudUIComponentTypeID: - return V1CloudUIComponentTypeID.TEXT - - -@ioprepped -@dataclass -class V1CloudUIButton: - """A button in our cloud ui.""" - - color: Annotated[tuple[float, float, float], IOAttrs('cl')] - size: Annotated[tuple[float, float], IOAttrs('sz')] - components: Annotated[list[V1CloudUIComponent], IOAttrs('c')] - scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 - - -@ioprepped -@dataclass -class V1CloudUIRow: - """A row in our cloud ui.""" - - buttons: Annotated[list[V1CloudUIButton], IOAttrs('b')] - - -@ioprepped -@dataclass -class V1CloudUI(CloudUI): - """Version 1 of our cloud-defined UI type.""" - - rows: Annotated[list[V1CloudUIRow], IOAttrs('r')] - - @override - @classmethod - def get_type_id(cls) -> CloudUITypeID: - return CloudUITypeID.V1 diff --git a/dist/ba_data/python/bacommon/build.py b/dist/ba_data/python/bacommon/build.py index 7e26ed5..6d440f7 100644 --- a/dist/ba_data/python/bacommon/build.py +++ b/dist/ba_data/python/bacommon/build.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to game builds.""" +"""Functionality related to game builds. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations diff --git a/dist/ba_data/python/bacommon/classic/__init__.py b/dist/ba_data/python/bacommon/classic/__init__.py new file mode 100644 index 0000000..fbf3bcb --- /dev/null +++ b/dist/ba_data/python/bacommon/classic/__init__.py @@ -0,0 +1,71 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Functionality related to bombsquad classic. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from bacommon.classic._account import ( + ClassicLiveAccountClientData, +) +from bacommon.classic._classic import ( + TOKENS1_COUNT, + TOKENS2_COUNT, + TOKENS3_COUNT, + TOKENS4_COUNT, +) +from bacommon.classic._chest import ( + ClassicChestAppearance, + ClassicChestDisplayItem, +) +from bacommon.classic._msg import ( + GetClassicLeaguePresidentButtonInfoMessage, + GetClassicLeaguePresidentButtonInfoResponse, + ChestInfoMessage, + ChestInfoResponse, + GetClassicPurchasesMessage, + GetClassicPurchasesResponse, + GlobalProfileCheckMessage, + GlobalProfileCheckResponse, + InboxRequestMessage, + InboxRequestResponse, + LegacyRequest, + LegacyResponse, + PrivatePartyMessage, + PrivatePartyResponse, + ScoreSubmitMessage, + ScoreSubmitResponse, + SendInfoMessage, + SendInfoResponse, +) + +__all__ = [ + 'ChestInfoMessage', + 'ChestInfoResponse', + 'ClassicLiveAccountClientData', + 'ClassicChestAppearance', + 'ClassicChestDisplayItem', + 'GetClassicLeaguePresidentButtonInfoMessage', + 'GetClassicLeaguePresidentButtonInfoResponse', + 'GetClassicPurchasesMessage', + 'GetClassicPurchasesResponse', + 'GlobalProfileCheckMessage', + 'GlobalProfileCheckResponse', + 'InboxRequestMessage', + 'InboxRequestResponse', + 'LegacyRequest', + 'LegacyResponse', + 'PrivatePartyMessage', + 'PrivatePartyResponse', + 'ScoreSubmitMessage', + 'ScoreSubmitResponse', + 'SendInfoMessage', + 'SendInfoResponse', + 'TOKENS1_COUNT', + 'TOKENS2_COUNT', + 'TOKENS3_COUNT', + 'TOKENS4_COUNT', +] diff --git a/dist/ba_data/python/bacommon/bs/_account.py b/dist/ba_data/python/bacommon/classic/_account.py similarity index 86% rename from dist/ba_data/python/bacommon/bs/_account.py rename to dist/ba_data/python/bacommon/classic/_account.py index da8b9fe..41460f5 100644 --- a/dist/ba_data/python/bacommon/bs/_account.py +++ b/dist/ba_data/python/bacommon/classic/_account.py @@ -10,12 +10,12 @@ from dataclasses import dataclass from typing import Annotated from efro.dataclassio import ioprepped, IOAttrs -from bacommon.bs._chest import ClassicChestAppearance +from bacommon.classic._chest import ClassicChestAppearance @ioprepped @dataclass -class ClassicAccountLiveData: +class ClassicLiveAccountClientData: """Live account data fed to the client in the bs classic app mode.""" @dataclass @@ -44,6 +44,12 @@ class ClassicAccountLiveData: ASK_FOR_REVIEW = 'r' + class StoreStyle(Enum): + """Special looks for the store.""" + + NORMAL = 'n' + SANTA = 's' + tickets: Annotated[int, IOAttrs('ti')] tokens: Annotated[int, IOAttrs('to')] @@ -71,3 +77,7 @@ class ClassicAccountLiveData: purchases_state: Annotated[str | None, IOAttrs('p')] flags: Annotated[set[Flag], IOAttrs('f', soft_default_factory=set)] + + store_style: Annotated[ + StoreStyle, IOAttrs('s', enum_fallback=StoreStyle.NORMAL) + ] diff --git a/dist/ba_data/python/bacommon/bs/_chest.py b/dist/ba_data/python/bacommon/classic/_chest.py similarity index 62% rename from dist/ba_data/python/bacommon/bs/_chest.py rename to dist/ba_data/python/bacommon/classic/_chest.py index ba87b94..849e9af 100644 --- a/dist/ba_data/python/bacommon/bs/_chest.py +++ b/dist/ba_data/python/bacommon/classic/_chest.py @@ -5,7 +5,11 @@ from __future__ import annotations from enum import Enum -from typing import assert_never +from typing import assert_never, Annotated, override +from dataclasses import dataclass + +from efro.dataclassio import ioprepped, IOAttrs +import bacommon.displayitem as ditm class ClassicChestAppearance(Enum): @@ -24,7 +28,7 @@ class ClassicChestAppearance(Enum): def pretty_name(self) -> str: """Pretty name for the chest in English.""" # pylint: disable=too-many-return-statements - cls = type(self) + cls = ClassicChestAppearance if self is cls.UNKNOWN: return 'Unknown Chest' @@ -44,3 +48,20 @@ class ClassicChestAppearance(Enum): return 'L6 Chest' assert_never(self) + + +@ioprepped +@dataclass +class ClassicChestDisplayItem(ditm.Item): + """Display a chest.""" + + appearance: Annotated[ClassicChestAppearance, IOAttrs('a')] + + @override + @classmethod + def get_type_id(cls) -> ditm.ItemTypeID: + return ditm.ItemTypeID.CHEST + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return self.appearance.pretty_name, [] diff --git a/dist/ba_data/python/bacommon/bs/_bs.py b/dist/ba_data/python/bacommon/classic/_classic.py similarity index 100% rename from dist/ba_data/python/bacommon/bs/_bs.py rename to dist/ba_data/python/bacommon/classic/_classic.py diff --git a/dist/ba_data/python/bacommon/classic/_displayitem.py b/dist/ba_data/python/bacommon/classic/_displayitem.py new file mode 100644 index 0000000..917aa24 --- /dev/null +++ b/dist/ba_data/python/bacommon/classic/_displayitem.py @@ -0,0 +1,28 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Display-item bits of classic.""" + +# from __future__ import annotations + +# from enum import Enum +# from typing import assert_never, Annotated, override +# from dataclasses import dataclass + +# from efro.dataclassio import ioprepped, IOAttrs +# import bacommon.displayitem as ditm + +# @ioprepped +# @dataclass +# class ClassicCharacterDisplayItem(ditm.Item): +# """Display a character.""" + +# : Annotated[ClassicChestAppearance, IOAttrs('a')] + +# @override +# @classmethod +# def get_type_id(cls) -> ditm.ItemTypeID: +# return ditm.ItemTypeID.CHEST + +# @override +# def get_description(self) -> tuple[str, list[tuple[str, str]]]: +# return self.appearance.pretty_name, [] diff --git a/dist/ba_data/python/bacommon/bs/_msg.py b/dist/ba_data/python/bacommon/classic/_msg.py similarity index 59% rename from dist/ba_data/python/bacommon/bs/_msg.py rename to dist/ba_data/python/bacommon/classic/_msg.py index 77e9ff5..198e8e4 100644 --- a/dist/ba_data/python/bacommon/bs/_msg.py +++ b/dist/ba_data/python/bacommon/classic/_msg.py @@ -5,125 +5,38 @@ from __future__ import annotations import datetime -from enum import Enum from dataclasses import dataclass, field from typing import Annotated, override from efro.dataclassio import ioprepped, IOAttrs from efro.message import Message, Response -from bacommon.bs._displayitem import DisplayItemWrapper -from bacommon.bs._clienteffect import ClientEffect -from bacommon.bs._clouddialog import CloudDialogAction, CloudDialogWrapper -from bacommon.bs._chest import ClassicChestAppearance +import bacommon.displayitem as ditm +import bacommon.clouddialog as cdlg +import bacommon.clienteffect as clfx +from bacommon.classic._chest import ClassicChestAppearance @ioprepped @dataclass -class ChestActionMessage(Message): - """Request action about a chest.""" +class GetClassicLeaguePresidentButtonInfoMessage(Message): + """Curious who is president of my league?..""" - class Action(Enum): - """Types of actions we can request.""" - - # Unlocking (for free or with tokens). - UNLOCK = 'u' - - # Watched an ad to reduce wait. - AD = 'ad' - - action: Annotated[Action, IOAttrs('a')] - - # Tokens we are paying (only applies to unlock). - token_payment: Annotated[int, IOAttrs('t')] - - chest_id: Annotated[str, IOAttrs('i')] + season: Annotated[str | None, IOAttrs('s')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: - return [ChestActionResponse] + return [GetClassicLeaguePresidentButtonInfoResponse] @ioprepped @dataclass -class ChestActionResponse(Response): - """Here's the results of that action you asked for, boss.""" +class GetClassicLeaguePresidentButtonInfoResponse(Response): + """Here's that info about the president you asked for boss.""" - # Tokens that were actually charged. - tokens_charged: Annotated[int, IOAttrs('t')] = 0 - - # If present, signifies the chest has been opened and we should show - # the user this stuff that was in it. - contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None - - # If contents are present, which of the chest's prize-sets they - # represent. - prizeindex: Annotated[int, IOAttrs('i')] = 0 - - # Printable error if something goes wrong. - error: Annotated[str | None, IOAttrs('e')] = None - - # Printable warning. Shown in orange with an error sound. Does not - # mean the action failed; only that there's something to tell the - # users such as 'It looks like you are faking ad views; stop it or - # you won't have ad options anymore.' - warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None - - # Printable success message. Shown in green with a cash-register - # sound. Can be used for things like successful wait reductions via - # ad views. Used in builds earlier than 22311; can remove once - # 22311+ is ubiquitous. - success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None - - # Effects to show on the client. Replaces warning and success_msg in - # build 22311 or newer. - effects: Annotated[ - list[ClientEffect], IOAttrs('fx', store_default=False) - ] = field(default_factory=list) - - -@ioprepped -@dataclass -class CloudDialogActionMessage(Message): - """Do something to a client ui.""" - - id: Annotated[str, IOAttrs('i')] - action: Annotated[CloudDialogAction, IOAttrs('a')] - - @override - @classmethod - def get_response_types(cls) -> list[type[Response] | None]: - return [CloudDialogActionResponse] - - -@ioprepped -@dataclass -class CloudDialogActionResponse(Response): - """Did something to that inbox entry, boss.""" - - class ErrorType(Enum): - """Types of errors that may have occurred.""" - - # Probably a future error type we don't recognize. - UNKNOWN = 'u' - - # Something went wrong on the server, but specifics are not - # relevant. - INTERNAL = 'i' - - # The entry expired on the server. In various cases such as 'ok' - # buttons this can generally be ignored. - EXPIRED = 'e' - - error_type: Annotated[ - ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN) - ] - - # User facing error message in the case of errors. - error_message: Annotated[str | None, IOAttrs('em')] - - effects: Annotated[list[ClientEffect], IOAttrs('fx')] + # Lstr for the name shown on the button. + name: Annotated[str | None, IOAttrs('n')] @ioprepped @@ -183,7 +96,7 @@ class InboxRequestMessage(Message): class InboxRequestResponse(Response): """Here's that inbox contents you asked for, boss.""" - wrappers: Annotated[list[CloudDialogWrapper], IOAttrs('w')] + wrappers: Annotated[list[cdlg.Wrapper], IOAttrs('w')] # Printable error if something goes wrong. error: Annotated[str | None, IOAttrs('e')] = None @@ -241,7 +154,7 @@ class ChestInfoResponse(Response): """A possible set of prizes for this chest.""" weight: Annotated[float, IOAttrs('w')] - contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')] + contents: Annotated[list[ditm.Wrapper], IOAttrs('c')] appearance: Annotated[ ClassicChestAppearance, @@ -307,7 +220,7 @@ class ScoreSubmitResponse(Response): """Did something to that inbox entry, boss.""" # Things we should show on our end. - effects: Annotated[list[ClientEffect], IOAttrs('fx')] + effects: Annotated[list[clfx.Effect], IOAttrs('fx')] @ioprepped @@ -330,7 +243,7 @@ class SendInfoResponse(Response): handled: Annotated[bool, IOAttrs('v')] message: Annotated[str | None, IOAttrs('m', store_default=False)] = None - effects: Annotated[ - list[ClientEffect], IOAttrs('e', store_default=False) - ] = field(default_factory=list) + effects: Annotated[list[clfx.Effect], IOAttrs('e', store_default=False)] = ( + field(default_factory=list) + ) legacy_code: Annotated[str | None, IOAttrs('l', store_default=False)] = None diff --git a/dist/ba_data/python/bacommon/clienteffect.py b/dist/ba_data/python/bacommon/clienteffect.py new file mode 100644 index 0000000..aa9ce5a --- /dev/null +++ b/dist/ba_data/python/bacommon/clienteffect.py @@ -0,0 +1,224 @@ +# Released under the MIT License. See LICENSE for details. +# +"""ClientEffect related functionality. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + + +class EffectTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + LEGACY_SCREEN_MESSAGE = 'm' + SCREEN_MESSAGE = 'sm' + SOUND = 's' + DELAY = 'd' + CHEST_WAIT_TIME_ANIMATION = 't' + TICKETS_ANIMATION = 'ta' + TOKENS_ANIMATION = 'toa' + + +class Effect(IOMultiType[EffectTypeID]): + """Something that can happen on the client. + + This can include screen messages, sounds, visual effects, etc. + """ + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: EffectTypeID) -> type[Effect]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + # pylint: disable=too-many-return-statements + + t = EffectTypeID + if type_id is t.UNKNOWN: + return Unknown + if type_id is t.LEGACY_SCREEN_MESSAGE: + return LegacyScreenMessage + if type_id is t.SCREEN_MESSAGE: + return ScreenMessage + if type_id is t.SOUND: + return PlaySound + if type_id is t.DELAY: + return Delay + if type_id is t.CHEST_WAIT_TIME_ANIMATION: + return ChestWaitTimeAnimation + if type_id is t.TICKETS_ANIMATION: + return TicketsAnimation + if type_id is t.TOKENS_ANIMATION: + return TokensAnimation + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> Effect: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return Unknown() + + +@ioprepped +@dataclass +class Unknown(Effect): + """Fallback substitute for types we don't recognize.""" + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.UNKNOWN + + +@ioprepped +@dataclass +class LegacyScreenMessage(Effect): + """Display a screen-message (Legacy version). + + This will be processed as an Lstr with translation category + 'serverResponses'. + + When possible, migrate to using :class:`ScreenMessage`. + """ + + message: Annotated[str, IOAttrs('m')] + subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( + default_factory=list + ) + color: Annotated[ + tuple[float, float, float], IOAttrs('c', store_default=False) + ] = (1.0, 1.0, 1.0) + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.LEGACY_SCREEN_MESSAGE + + +@ioprepped +@dataclass +class ScreenMessage(Effect): + """Display a screen-message. + + Supported on engine build 22606 or newer. + + This version does no translation by default (expecting translation + to happen server-side). Pass a Lstr json string and set is_lstr=True + for client-side translation. + """ + + message: Annotated[str, IOAttrs('m')] + color: Annotated[ + tuple[float, float, float], IOAttrs('c', store_default=False) + ] = (1.0, 1.0, 1.0) + is_lstr: Annotated[bool, IOAttrs('l', store_default=False)] = False + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.SCREEN_MESSAGE + + +class Sound(Enum): + """Sounds that can be played.""" + + UNKNOWN = 'u' + CASH_REGISTER = 'c' + ERROR = 'e' + POWER_DOWN = 'p' + GUN_COCKING = 'g' + + +@ioprepped +@dataclass +class PlaySound(Effect): + """Play a sound.""" + + sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)] + volume: Annotated[float, IOAttrs('v', store_default=False)] = 1.0 + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.SOUND + + +@ioprepped +@dataclass +class ChestWaitTimeAnimation(Effect): + """Animate chest wait time changing.""" + + chestid: Annotated[str, IOAttrs('c')] + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[datetime.datetime, IOAttrs('o')] + endvalue: Annotated[datetime.datetime, IOAttrs('n')] + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.CHEST_WAIT_TIME_ANIMATION + + +@ioprepped +@dataclass +class TicketsAnimation(Effect): + """Animate tickets count.""" + + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[int, IOAttrs('s')] + endvalue: Annotated[int, IOAttrs('e')] + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.TICKETS_ANIMATION + + +@ioprepped +@dataclass +class TokensAnimation(Effect): + """Animate tokens count.""" + + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[int, IOAttrs('s')] + endvalue: Annotated[int, IOAttrs('e')] + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.TOKENS_ANIMATION + + +@ioprepped +@dataclass +class Delay(Effect): + """Delay effect processing.""" + + seconds: Annotated[float, IOAttrs('s')] + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.DELAY diff --git a/dist/ba_data/python/bacommon/cloud.py b/dist/ba_data/python/bacommon/cloud.py index 6a58091..c12690b 100644 --- a/dist/ba_data/python/bacommon/cloud.py +++ b/dist/ba_data/python/bacommon/cloud.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to cloud functionality.""" +"""Cloud related functionality. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations @@ -10,9 +16,13 @@ from typing import TYPE_CHECKING, Annotated, override from efro.message import Message, Response from efro.dataclassio import ioprepped, IOAttrs +from bacommon.analytics import AnalyticsEvent from bacommon.securedata import SecureDataChecker from bacommon.transfer import DirectoryManifest from bacommon.login import LoginType +from bacommon.docui import DocUIRequest, DocUIResponse +import bacommon.displayitem as ditm +import bacommon.clienteffect as clfx if TYPE_CHECKING: pass @@ -357,3 +367,120 @@ class CloudValsResponse(Response): """Here's them cloud vals ya asked for, boss.""" vals: Annotated[CloudVals, IOAttrs('v')] + + +@ioprepped +@dataclass +class ChestActionMessage(Message): + """Request action about a chest.""" + + class Action(Enum): + """Types of actions we can request.""" + + # Unlocking (for free or with tokens). + UNLOCK = 'u' + + # Watched an ad to reduce wait. + AD = 'ad' + + action: Annotated[Action, IOAttrs('a')] + + # Tokens we are paying (only applies to unlock). + token_payment: Annotated[int, IOAttrs('t')] + + chest_id: Annotated[str, IOAttrs('i')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [ChestActionResponse] + + +@ioprepped +@dataclass +class ChestActionResponse(Response): + """Here's the results of that action you asked for, boss.""" + + # Tokens that were actually charged. + tokens_charged: Annotated[int, IOAttrs('t')] = 0 + + # If present, signifies the chest has been opened and we should show + # the user this stuff that was in it. + contents: Annotated[list[ditm.Wrapper] | None, IOAttrs('c')] = None + + # If contents are present, which of the chest's prize-sets they + # represent. + prizeindex: Annotated[int, IOAttrs('i')] = 0 + + # Printable error if something goes wrong. + error: Annotated[str | None, IOAttrs('e')] = None + + # Printable warning. Shown in orange with an error sound. Does not + # mean the action failed; only that there's something to tell the + # users such as 'It looks like you are faking ad views; stop it or + # you won't have ad options anymore.' + warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None + + # Printable success message. Shown in green with a cash-register + # sound. Can be used for things like successful wait reductions via + # ad views. Used in builds earlier than 22311; can remove once + # 22311+ is ubiquitous. + success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None + + # Effects to show on the client. Replaces warning and success_msg in + # build 22311 or newer. + effects: Annotated[ + list[clfx.Effect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + +@ioprepped +@dataclass +class FulfillDocUIRequest(Message): + """Can a fella get a doc-ui round here?""" + + request: Annotated[DocUIRequest, IOAttrs('r')] + domain: Annotated[str, IOAttrs('d')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [FulfillDocUIResponse] + + +@ioprepped +@dataclass +class FulfillDocUIResponse(Response): + """Here's that doc-ui you asked for, boss.""" + + response: Annotated[DocUIResponse, IOAttrs('r')] + + +@ioprepped +@dataclass +class AnalyticsEventMessage(Message): + """Have a nice analytics event!""" + + event: Annotated[AnalyticsEvent, IOAttrs('e')] + + +@ioprepped +@dataclass +class AuthRequestMessage(Message): + """Request access to a server for a current account.""" + + global_app_instance_uuid: Annotated[str, IOAttrs('a')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [AuthRequestResponse] + + +@ioprepped +@dataclass +class AuthRequestResponse(Response): + """Here's that access ya asked for boss.""" + + error: Annotated[str | None, IOAttrs('e')] + token: Annotated[str | None, IOAttrs('t')] diff --git a/dist/ba_data/python/bacommon/clouddialog/__init__.py b/dist/ba_data/python/bacommon/clouddialog/__init__.py new file mode 100644 index 0000000..c6e6a4c --- /dev/null +++ b/dist/ba_data/python/bacommon/clouddialog/__init__.py @@ -0,0 +1,29 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Functionality related to cloud-dialogs. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from bacommon.clouddialog._clouddialog import ( + CloudDialogTypeID, + CloudDialog, + Unknown, + Wrapper, + Action, + ActionMessage, + ActionResponse, +) + +__all__ = [ + 'CloudDialogTypeID', + 'CloudDialog', + 'Unknown', + 'Wrapper', + 'Action', + 'ActionMessage', + 'ActionResponse', +] diff --git a/dist/ba_data/python/bacommon/clouddialog/_clouddialog.py b/dist/ba_data/python/bacommon/clouddialog/_clouddialog.py new file mode 100644 index 0000000..4ec1015 --- /dev/null +++ b/dist/ba_data/python/bacommon/clouddialog/_clouddialog.py @@ -0,0 +1,142 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Simple cloud-defined UIs for things like notifications. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType +from efro.message import Message, Response + +import bacommon.clienteffect as clfx + + +class CloudDialogTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + BASIC = 'b' + + +class CloudDialog(IOMultiType[CloudDialogTypeID]): + """Small self-contained ui bit provided by the cloud. + + These take care of updating and/or dismissing themselves based on + user input. Useful for things such as inbox messages. For more + complex UI construction, look at :mod:`bacommon.docui`. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: CloudDialogTypeID) -> type[CloudDialog]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = CloudDialogTypeID + + if type_id is t.UNKNOWN: + return Unknown + + if type_id is t.BASIC: + from bacommon.clouddialog.basic import BasicCloudDialog + + return BasicCloudDialog + + # Make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> CloudDialog: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return Unknown() + + +@ioprepped +@dataclass +class Unknown(CloudDialog): + """Fallback type for unrecognized entries.""" + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + return CloudDialogTypeID.UNKNOWN + + +@ioprepped +@dataclass +class Wrapper: + """Wrapper for a CloudDialog and its common data.""" + + id: Annotated[str, IOAttrs('i')] + createtime: Annotated[datetime.datetime, IOAttrs('c')] + ui: Annotated[CloudDialog, IOAttrs('e')] + + +class Action(Enum): + """Types of actions we can run.""" + + BUTTON_PRESS_POSITIVE = 'p' + BUTTON_PRESS_NEGATIVE = 'n' + + +@ioprepped +@dataclass +class ActionMessage(Message): + """Do something to a client ui.""" + + id: Annotated[str, IOAttrs('i')] + action: Annotated[Action, IOAttrs('a')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [ActionResponse] + + +@ioprepped +@dataclass +class ActionResponse(Response): + """Did something to that inbox entry, boss.""" + + class ErrorType(Enum): + """Types of errors that may have occurred.""" + + # Probably a future error type we don't recognize. + UNKNOWN = 'u' + + # Something went wrong on the server, but specifics are not + # relevant. + INTERNAL = 'i' + + # The entry expired on the server. In various cases such as 'ok' + # buttons this can generally be ignored. + EXPIRED = 'e' + + error_type: Annotated[ + ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN) + ] + + # User facing error message in the case of errors. + error_message: Annotated[str | None, IOAttrs('em')] + + effects: Annotated[list[clfx.Effect], IOAttrs('fx')] diff --git a/dist/ba_data/python/bacommon/clouddialog/basic.py b/dist/ba_data/python/bacommon/clouddialog/basic.py new file mode 100644 index 0000000..635b2fb --- /dev/null +++ b/dist/ba_data/python/bacommon/clouddialog/basic.py @@ -0,0 +1,233 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Basic cloud-dialog.""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + +import bacommon.displayitem as ditm +from bacommon.clouddialog._clouddialog import CloudDialog, CloudDialogTypeID + + +class ComponentTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + TEXT = 't' + LINK = 'l' + BS_CLASSIC_TOURNEY_RESULT = 'ct' + DISPLAY_ITEMS = 'di' + EXPIRE_TIME = 'd' + + +class Component(IOMultiType[ComponentTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: ComponentTypeID) -> type[Component]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = ComponentTypeID + if type_id is t.UNKNOWN: + return Unknown + if type_id is t.TEXT: + return Text + if type_id is t.LINK: + return Link + if type_id is t.BS_CLASSIC_TOURNEY_RESULT: + return ClassicTourneyResult + if type_id is t.DISPLAY_ITEMS: + return DisplayItems + if type_id is t.EXPIRE_TIME: + return ExpireTime + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> Component: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return Unknown() + + +@ioprepped +@dataclass +class Unknown(Component): + """An unknown basic client component type. + + In practice these should never show up since the master-server + generates these on the fly for the client and so should not send + clients one they can't digest. + """ + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.UNKNOWN + + +@ioprepped +@dataclass +class Text(Component): + """Show some text in the inbox message.""" + + text: Annotated[str, IOAttrs('t')] + subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( + default_factory=list + ) + scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 + color: Annotated[ + tuple[float, float, float, float], IOAttrs('c', store_default=False) + ] = (1.0, 1.0, 1.0, 1.0) + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.TEXT + + +@ioprepped +@dataclass +class Link(Component): + """Show a link in the inbox message.""" + + url: Annotated[str, IOAttrs('u')] + label: Annotated[str, IOAttrs('l')] + subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( + default_factory=list + ) + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.LINK + + +@ioprepped +@dataclass +class ClassicTourneyResult(Component): + """Show info about a classic tourney.""" + + tournament_id: Annotated[str, IOAttrs('t')] + game: Annotated[str, IOAttrs('g')] + players: Annotated[int, IOAttrs('p')] + rank: Annotated[int, IOAttrs('r')] + trophy: Annotated[str | None, IOAttrs('tr')] + prizes: Annotated[list[ditm.Wrapper], IOAttrs('pr')] + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.BS_CLASSIC_TOURNEY_RESULT + + +@ioprepped +@dataclass +class DisplayItems(Component): + """Show some display-items.""" + + items: Annotated[list[ditm.Wrapper], IOAttrs('d')] + width: Annotated[float, IOAttrs('w')] = 100.0 + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.DISPLAY_ITEMS + + +@ioprepped +@dataclass +class ExpireTime(Component): + """Show expire-time.""" + + time: Annotated[datetime.datetime, IOAttrs('d')] + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> ComponentTypeID: + return ComponentTypeID.EXPIRE_TIME + + +class ButtonLabel(Enum): + """Distinct button labels we support.""" + + UNKNOWN = 'u' + OK = 'o' + APPLY = 'a' + CANCEL = 'c' + ACCEPT = 'ac' + DECLINE = 'dn' + IGNORE = 'ig' + CLAIM = 'cl' + DISCARD = 'd' + + +class InteractionStyle(Enum): + """Overall interaction styles we support.""" + + UNKNOWN = 'u' + BUTTON_POSITIVE = 'p' + BUTTON_POSITIVE_NEGATIVE = 'pn' + + +@ioprepped +@dataclass +class BasicCloudDialog(CloudDialog): + """A basic UI for the client.""" + + components: Annotated[list[Component], IOAttrs('s')] + + interaction_style: Annotated[ + InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN) + ] = InteractionStyle.BUTTON_POSITIVE + + button_label_positive: Annotated[ + ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN) + ] = ButtonLabel.OK + + button_label_negative: Annotated[ + ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN) + ] = ButtonLabel.CANCEL + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + return CloudDialogTypeID.BASIC + + def contains_unknown_elements(self) -> bool: + """Whether something within us is an unknown type or enum.""" + return ( + self.interaction_style is InteractionStyle.UNKNOWN + or self.button_label_positive is ButtonLabel.UNKNOWN + or self.button_label_negative is ButtonLabel.UNKNOWN + or any( + c.get_type_id() is ComponentTypeID.UNKNOWN + for c in self.components + ) + ) diff --git a/dist/ba_data/python/bacommon/bs/_displayitem.py b/dist/ba_data/python/bacommon/displayitem.py similarity index 58% rename from dist/ba_data/python/bacommon/bs/_displayitem.py rename to dist/ba_data/python/bacommon/displayitem.py index 0593e31..566695b 100644 --- a/dist/ba_data/python/bacommon/bs/_displayitem.py +++ b/dist/ba_data/python/bacommon/displayitem.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""DisplayItem related functionality.""" +"""Functionality for displaying currencies, prizes, owned items, etc. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations @@ -11,20 +17,19 @@ from typing import Annotated, override, assert_never from efro.util import pairs_to_flat from efro.dataclassio import ioprepped, IOAttrs, IOMultiType -from bacommon.bs._chest import ClassicChestAppearance - -class DisplayItemTypeID(Enum): +class ItemTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' TICKETS = 't' + TICKETS_PURPLE = 'tp' TOKENS = 'k' TEST = 's' CHEST = 'c' -class DisplayItem(IOMultiType[DisplayItemTypeID]): +class Item(IOMultiType[ItemTypeID]): """Some amount of something that can be shown or described. Used to depict chest contents, inventory, rewards, etc. @@ -32,7 +37,7 @@ class DisplayItem(IOMultiType[DisplayItemTypeID]): @override @classmethod - def get_type_id(cls) -> DisplayItemTypeID: + def get_type_id(cls) -> ItemTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. @@ -40,21 +45,25 @@ class DisplayItem(IOMultiType[DisplayItemTypeID]): @override @classmethod - def get_type(cls, type_id: DisplayItemTypeID) -> type[DisplayItem]: + def get_type(cls, type_id: ItemTypeID) -> type[Item]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import - t = DisplayItemTypeID + t = ItemTypeID if type_id is t.UNKNOWN: - return UnknownDisplayItem + return Unknown if type_id is t.TICKETS: - return TicketsDisplayItem + return Tickets + if type_id is t.TICKETS_PURPLE: + return PurpleTickets if type_id is t.TOKENS: - return TokensDisplayItem + return Tokens if type_id is t.TEST: - return TestDisplayItem + return Test if type_id is t.CHEST: - return ChestDisplayItem + from bacommon.classic._chest import ClassicChestDisplayItem + + return ClassicChestDisplayItem # Important to make sure we provide all types. assert_never(type_id) @@ -62,31 +71,34 @@ class DisplayItem(IOMultiType[DisplayItemTypeID]): def get_description(self) -> tuple[str, list[tuple[str, str]]]: """Return a string description and subs for the item. - These decriptions are baked into the DisplayItemWrapper and + Will be translated on the client using the 'displayItemNames' + Lstr category. + + These decriptions are baked into the display-item wrapper and should be accessed from there when available. This allows - clients to give descriptions even for newer display items they - don't recognize. + clients to give descriptions even for newer display item types + they don't recognize. """ raise NotImplementedError() # Implement fallbacks so client can digest item lists even if they - # contain unrecognized stuff. DisplayItemWrapper contains basic + # contain unrecognized stuff. The wrapper contains basic # baked down info that they can still use in such cases. @override @classmethod - def get_unknown_type_fallback(cls) -> DisplayItem: - return UnknownDisplayItem() + def get_unknown_type_fallback(cls) -> Item: + return Unknown() @ioprepped @dataclass -class UnknownDisplayItem(DisplayItem): +class Unknown(Item): """Something we don't know how to display.""" @override @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.UNKNOWN + def get_type_id(cls) -> ItemTypeID: + return ItemTypeID.UNKNOWN @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: @@ -94,23 +106,23 @@ class UnknownDisplayItem(DisplayItem): # Make noise but don't break. logging.exception( - 'UnknownDisplayItem.get_description() should never be called.' - ' Always access descriptions on the DisplayItemWrapper.' + 'Unknown.get_description() should never be called.' + ' Always access descriptions on the display-item wrapper.' ) return 'Unknown', [] @ioprepped @dataclass -class TicketsDisplayItem(DisplayItem): +class Tickets(Item): """Some amount of tickets.""" count: Annotated[int, IOAttrs('c')] @override @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TICKETS + def get_type_id(cls) -> ItemTypeID: + return ItemTypeID.TICKETS @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: @@ -119,15 +131,32 @@ class TicketsDisplayItem(DisplayItem): @ioprepped @dataclass -class TokensDisplayItem(DisplayItem): +class PurpleTickets(Item): + """Some amount of purple tickets.""" + + count: Annotated[int, IOAttrs('c')] + + @override + @classmethod + def get_type_id(cls) -> ItemTypeID: + return ItemTypeID.TICKETS_PURPLE + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return '${C} Purple Tickets', [('${C}', str(self.count))] + + +@ioprepped +@dataclass +class Tokens(Item): """Some amount of tokens.""" count: Annotated[int, IOAttrs('c')] @override @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TOKENS + def get_type_id(cls) -> ItemTypeID: + return ItemTypeID.TOKENS @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: @@ -136,47 +165,34 @@ class TokensDisplayItem(DisplayItem): @ioprepped @dataclass -class TestDisplayItem(DisplayItem): +class Test(Item): """Fills usable space for a display-item - good for calibration.""" @override @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.TEST + def get_type_id(cls) -> ItemTypeID: + return ItemTypeID.TEST @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return 'Test Display Item Here', [] + return 'Test', [] @ioprepped @dataclass -class ChestDisplayItem(DisplayItem): - """Display a chest.""" +class Wrapper: + """Wraps a display-item and some baked out info. - appearance: Annotated[ClassicChestAppearance, IOAttrs('a')] + This allows clients to at least give descriptions of new + display-item types they may not have locally. + """ - @override - @classmethod - def get_type_id(cls) -> DisplayItemTypeID: - return DisplayItemTypeID.CHEST - - @override - def get_description(self) -> tuple[str, list[tuple[str, str]]]: - return self.appearance.pretty_name, [] - - -@ioprepped -@dataclass -class DisplayItemWrapper: - """Wraps a DisplayItem and common info.""" - - item: Annotated[DisplayItem, IOAttrs('i')] + item: Annotated[Item, IOAttrs('i')] description: Annotated[str, IOAttrs('d')] description_subs: Annotated[list[str] | None, IOAttrs('s')] @classmethod - def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper: - """Convenience method to wrap a DisplayItem.""" + def for_item(cls, item: Item) -> Wrapper: + """Convenience method to wrap a display-item.""" desc, subs = item.get_description() - return DisplayItemWrapper(item, desc, pairs_to_flat(subs)) + return Wrapper(item, desc, pairs_to_flat(subs)) diff --git a/dist/ba_data/python/bacommon/docui/__init__.py b/dist/ba_data/python/bacommon/docui/__init__.py new file mode 100644 index 0000000..9d72511 --- /dev/null +++ b/dist/ba_data/python/bacommon/docui/__init__.py @@ -0,0 +1,30 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Declarative UI system. + +A high level way to build UIs that lives as a layer on top of engine +apis such as :mod:`bauiv1`. UIs can easily be serialized to json data +and be provided by webservers or other local or remote sources. +""" + +from bacommon.docui._docui import ( + DocUIRequest, + DocUIRequestTypeID, + UnknownDocUIRequest, + DocUIResponse, + DocUIResponseTypeID, + UnknownDocUIResponse, + DocUIWebRequest, + DocUIWebResponse, +) + +__all__ = [ + 'DocUIRequest', + 'DocUIRequestTypeID', + 'UnknownDocUIRequest', + 'DocUIResponse', + 'DocUIResponseTypeID', + 'UnknownDocUIResponse', + 'DocUIWebRequest', + 'DocUIWebResponse', +] diff --git a/dist/ba_data/python/bacommon/docui/_docui.py b/dist/ba_data/python/bacommon/docui/_docui.py new file mode 100644 index 0000000..d7b8419 --- /dev/null +++ b/dist/ba_data/python/bacommon/docui/_docui.py @@ -0,0 +1,172 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Version 1 of our doc-ui system.""" + +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass +from typing import override, assert_never, TYPE_CHECKING, Annotated + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType +from bacommon.locale import Locale + +if TYPE_CHECKING: + pass + + +class DocUIRequestTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + V1 = 'v1' + + +class DocUIRequest(IOMultiType[DocUIRequestTypeID]): + """A request for some UI.""" + + @override + @classmethod + def get_type_id(cls) -> DocUIRequestTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: DocUIRequestTypeID) -> type[DocUIRequest]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = DocUIRequestTypeID + if type_id is t.UNKNOWN: + return UnknownDocUIRequest + if type_id is t.V1: + from bacommon.docui.v1 import Request + + return Request + + # Make sure we cover all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> DocUIRequest: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownDocUIRequest() + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + +@ioprepped +@dataclass +class UnknownDocUIRequest(DocUIRequest): + """Fallback type for unrecognized UI types. + + Will show the client a 'cannot display this UI' placeholder request. + """ + + @override + @classmethod + def get_type_id(cls) -> DocUIRequestTypeID: + return DocUIRequestTypeID.UNKNOWN + + +class DocUIResponseTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + V1 = 'v1' + + +class DocUIResponse(IOMultiType[DocUIResponseTypeID]): + """A UI provied in response to a :class:`DocUIRequest`.""" + + @override + @classmethod + def get_type_id(cls) -> DocUIResponseTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: DocUIResponseTypeID) -> type[DocUIResponse]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = DocUIResponseTypeID + if type_id is t.UNKNOWN: + return UnknownDocUIResponse + if type_id is t.V1: + from bacommon.docui.v1 import Response + + return Response + + # Make sure we cover all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> DocUIResponse: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownDocUIResponse() + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + +@ioprepped +@dataclass +class UnknownDocUIResponse(DocUIResponse): + """Fallback type for unrecognized UI types. + + Will show the client a 'cannot display this UI' placeholder response. + """ + + @override + @classmethod + def get_type_id(cls) -> DocUIResponseTypeID: + return DocUIResponseTypeID.UNKNOWN + + +@ioprepped +@dataclass +class DocUIWebRequest: + """Complete data sent for doc-ui http requests.""" + + #: The wrapped doc-ui request. + doc_ui_request: Annotated[DocUIRequest, IOAttrs('r')] + + #: The current locale of the client. doc-ui generally deals in raw + #: strings and expects localization to happen on the server. + locale: Annotated[Locale, IOAttrs('l')] + + #: Engine build number. In some cases it may make sense to adjust + #: responses depending on available engine features. + engine_build_number: Annotated[int, IOAttrs('b')] + + +@ioprepped +@dataclass +class DocUIWebResponse: + """Complete data returned for doc-ui http requests.""" + + #: Human readable error string (if an error occurs). Either this or + #: doc_ui_response should be set; not both. + error: Annotated[str | None, IOAttrs('e', store_default=False)] = None + + #: doc-ui response. Either this or error should be set; not both. + doc_ui_response: Annotated[ + DocUIResponse | None, + IOAttrs('r', store_default=False), + ] = None diff --git a/dist/ba_data/python/bacommon/docui/v1.py b/dist/ba_data/python/bacommon/docui/v1.py new file mode 100644 index 0000000..91f8afc --- /dev/null +++ b/dist/ba_data/python/bacommon/docui/v1.py @@ -0,0 +1,783 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Version 1 doc-ui types.""" + +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + +import bacommon.displayitem as ditm +import bacommon.clienteffect as clfx +from bacommon.docui._docui import ( + DocUIRequest, + DocUIRequestTypeID, + DocUIResponse, + DocUIResponseTypeID, +) + + +class RequestMethod(Enum): + """Typeof of requests that can be made to doc-ui servers.""" + + #: An unknown request method. This can appear if a newer client is + #: requesting some method from an older server that is not known to + #: the server. + UNKNOWN = 'u' + + #: Fetch some resource. This can be retried and its results can + #: optionally be cached for some amount of time. + GET = 'g' + + #: Change some resource. This cannot be implicitly retried (at least + #: without deduplication), nor can it be cached. + POST = 'p' + + +@ioprepped +@dataclass +class Request(DocUIRequest): + """Full request to doc-ui.""" + + path: Annotated[str, IOAttrs('p')] + method: Annotated[ + RequestMethod, + IOAttrs('m', store_default=False, enum_fallback=RequestMethod.UNKNOWN), + ] = RequestMethod.GET + args: Annotated[dict, IOAttrs('r', store_default=False)] = field( + default_factory=dict + ) + + @override + @classmethod + def get_type_id(cls) -> DocUIRequestTypeID: + return DocUIRequestTypeID.V1 + + +class ActionTypeID(Enum): + """Type ID for each of our subclasses.""" + + BROWSE = 'b' + REPLACE = 'r' + LOCAL = 'l' + UNKNOWN = 'u' + + +class Action(IOMultiType[ActionTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> ActionTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: ActionTypeID) -> type[Action]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = ActionTypeID + if type_id is t.BROWSE: + return Browse + if type_id is t.REPLACE: + return Replace + if type_id is t.LOCAL: + return Local + if type_id is t.UNKNOWN: + return UnknownAction + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + @override + @classmethod + def get_unknown_type_fallback(cls) -> Action: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownAction() + + +@ioprepped +@dataclass +class UnknownAction(Action): + """Action type we don't recognize.""" + + @override + @classmethod + def get_type_id(cls) -> ActionTypeID: + return ActionTypeID.UNKNOWN + + +@ioprepped +@dataclass +class Browse(Action): + """Browse to a new page in a new window.""" + + request: Annotated[Request, IOAttrs('r')] + + #: Plays a swish. + default_sound: Annotated[bool, IOAttrs('ds', store_default=False)] = True + + #: Client-effects to run immediately when the button is pressed. + #: + #: :meta private: + immediate_client_effects: Annotated[ + list[clfx.Effect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + #: Local action to run immediately when the button is pressed. Will + #: be handled by + #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. + immediate_local_action: Annotated[ + str | None, IOAttrs('a', store_default=False) + ] = None + immediate_local_action_args: Annotated[ + dict | None, IOAttrs('aa', store_default=False) + ] = None + + @override + @classmethod + def get_type_id(cls) -> ActionTypeID: + return ActionTypeID.BROWSE + + +@ioprepped +@dataclass +class Replace(Action): + """Replace current page with a new one. + + Should be used to effectively 'modify' existing UIs by replacing + them with something slightly different. Things like scroll position + and selection will be carried across to the new layout when possible + to make for a seamless transition. + """ + + request: Annotated[Request, IOAttrs('r')] + + #: Plays a click if triggered by a button press. + default_sound: Annotated[bool, IOAttrs('ds', store_default=False)] = True + + #: Client-effects to run immediately when the button is pressed. + #: + #: :meta private: + immediate_client_effects: Annotated[ + list[clfx.Effect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + #: Local action to run immediately when the button is pressed. Will + #: be handled by + #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. + immediate_local_action: Annotated[ + str | None, IOAttrs('a', store_default=False) + ] = None + immediate_local_action_args: Annotated[ + dict | None, IOAttrs('aa', store_default=False) + ] = None + + @override + @classmethod + def get_type_id(cls) -> ActionTypeID: + return ActionTypeID.REPLACE + + +@ioprepped +@dataclass +class Local(Action): + """Perform only local actions; no new requests or page changes.""" + + close_window: Annotated[bool, IOAttrs('c', store_default=False)] = False + + #: Plays a swish if closing the window or a click if triggered by a + #: button press. + default_sound: Annotated[bool, IOAttrs('ds', store_default=False)] = True + + #: Client-effects to run immediately when the button is pressed. + #: + #: :meta private: + immediate_client_effects: Annotated[ + list[clfx.Effect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + #: Local action to run immediately when the button is pressed. Will + #: be handled by + #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. + immediate_local_action: Annotated[ + str | None, IOAttrs('a', store_default=False) + ] = None + immediate_local_action_args: Annotated[ + dict | None, IOAttrs('aa', store_default=False) + ] = None + + @override + @classmethod + def get_type_id(cls) -> ActionTypeID: + return ActionTypeID.LOCAL + + +class HAlign(Enum): + """Horizontal alignment.""" + + LEFT = 'l' + CENTER = 'c' + RIGHT = 'r' + + +class VAlign(Enum): + """Vertical alignment.""" + + TOP = 't' + CENTER = 'c' + BOTTOM = 'b' + + +class DecorationTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + TEXT = 't' + IMAGE = 'i' + DISPLAY_ITEM = 'd' + + +class Decoration(IOMultiType[DecorationTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> DecorationTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: DecorationTypeID) -> type[Decoration]: + # pylint: disable=cyclic-import + + t = DecorationTypeID + if type_id is t.UNKNOWN: + return UnknownDecoration + if type_id is t.TEXT: + return Text + if type_id is t.IMAGE: + return Image + if type_id is t.DISPLAY_ITEM: + return DisplayItem + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> Decoration: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownDecoration() + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + +@ioprepped +@dataclass +class UnknownDecoration(Decoration): + """An unknown decoration. + + In practice these should never show up since the master-server + generates these on the fly for the client and so should not send + clients one they can't digest. + """ + + @override + @classmethod + def get_type_id(cls) -> DecorationTypeID: + return DecorationTypeID.UNKNOWN + + +@ioprepped +@dataclass +class Text(Decoration): + """Text decoration.""" + + #: Note that doc-ui accepts only raw :class:`str` values for text; + #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language + #: support. + text: Annotated[str, IOAttrs('t')] + position: Annotated[tuple[float, float], IOAttrs('p')] + + #: Note that this effectively is max-width and max-height. + size: Annotated[tuple[float, float], IOAttrs('i')] + scale: Annotated[float, IOAttrs('s', store_default=False)] = 1.0 + h_align: Annotated[HAlign, IOAttrs('ha', store_default=False)] = ( + HAlign.CENTER + ) + v_align: Annotated[VAlign, IOAttrs('va', store_default=False)] = ( + VAlign.CENTER + ) + color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('c', store_default=False), + ] = None + flatness: Annotated[float | None, IOAttrs('f', store_default=False)] = None + shadow: Annotated[float | None, IOAttrs('sh', store_default=False)] = None + + is_lstr: Annotated[bool, IOAttrs('l', store_default=False)] = False + + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True + depth_range: Annotated[tuple[float, float] | None, IOAttrs('z')] = None + + #: Show max-width/height bounds; useful during development. + debug: Annotated[bool, IOAttrs('d', store_default=False)] = False + + @override + @classmethod + def get_type_id(cls) -> DecorationTypeID: + return DecorationTypeID.TEXT + + +@ioprepped +@dataclass +class Image(Decoration): + """Image decoration.""" + + texture: Annotated[str, IOAttrs('t')] + position: Annotated[tuple[float, float], IOAttrs('p')] + size: Annotated[tuple[float, float], IOAttrs('s')] + color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('c', store_default=False), + ] = None + h_align: Annotated[HAlign, IOAttrs('ha', store_default=False)] = ( + HAlign.CENTER + ) + v_align: Annotated[VAlign, IOAttrs('va', store_default=False)] = ( + VAlign.CENTER + ) + tint_texture: Annotated[str | None, IOAttrs('tt', store_default=False)] = ( + None + ) + tint_color: Annotated[ + tuple[float, float, float] | None, IOAttrs('tc1', store_default=False) + ] = None + tint2_color: Annotated[ + tuple[float, float, float] | None, IOAttrs('tc2', store_default=False) + ] = None + mask_texture: Annotated[str | None, IOAttrs('mt', store_default=False)] = ( + None + ) + mesh_opaque: Annotated[str | None, IOAttrs('mo', store_default=False)] = ( + None + ) + mesh_transparent: Annotated[ + str | None, IOAttrs('mn', store_default=False) + ] = None + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True + depth_range: Annotated[tuple[float, float] | None, IOAttrs('z')] = None + + @override + @classmethod + def get_type_id(cls) -> DecorationTypeID: + return DecorationTypeID.IMAGE + + +class DisplayItemStyle(Enum): + """Styles a display-item can be drawn in.""" + + #: Shows graphics and/or text fully conveying what the item is. Fits + #: in to a 4:3 box and works best with large-ish displays. + FULL = 'f' + + #: Graphics and/or text fully conveying what the item is, but + #: condensed to fit in a 2:1 box displayed at small sizes. + COMPACT = 'c' + + #: A graphics-only representation of the item (though text may be + #: used in fallback cases). Does not fully convey what the item is, + #: but instead is intended to be used alongside the item's textual + #: description. For example, some number of coins may simply display + #: a coin graphic here without the number. Draws in a 1:1 box and + #: works for large or small display. + ICON = 'i' + + +@ioprepped +@dataclass +class DisplayItem(Decoration): + """DisplayItem decoration.""" + + wrapper: Annotated[ditm.Wrapper, IOAttrs('w')] + position: Annotated[tuple[float, float], IOAttrs('p')] + size: Annotated[tuple[float, float], IOAttrs('s')] + style: Annotated[DisplayItemStyle, IOAttrs('t', store_default=False)] = ( + DisplayItemStyle.FULL + ) + text_color: Annotated[ + tuple[float, float, float] | None, IOAttrs('c', store_default=False) + ] = None + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True + depth_range: Annotated[tuple[float, float] | None, IOAttrs('z')] = None + debug: Annotated[bool, IOAttrs('d', store_default=False)] = False + + @override + @classmethod + def get_type_id(cls) -> DecorationTypeID: + return DecorationTypeID.DISPLAY_ITEM + + +class ButtonStyle(Enum): + """Styles a button can be.""" + + SQUARE = 'q' + TAB = 't' + SMALL = 's' + MEDIUM = 'm' + LARGE = 'l' + LARGER = 'xl' + BACK = 'b' + BACK_SMALL = 'bs' + SQUARE_WIDE = 'w' + + +@ioprepped +@dataclass +class Button: + """A button in our doc-ui. + + Note that size, padding, and all decorations are scaled consistently + with 'scale'. + """ + + #: Note that doc-ui accepts only raw :class:`str` values for text; + #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language + #: support. + label: Annotated[str | None, IOAttrs('l', store_default=False)] = None + + action: Annotated[Action | None, IOAttrs('a', store_default=False)] = None + + size: Annotated[ + tuple[float, float] | None, IOAttrs('sz', store_default=False) + ] = None + color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('cl', store_default=False), + ] = None + label_color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('lc', store_default=False), + ] = None + label_flatness: Annotated[ + float | None, IOAttrs('lf', store_default=False) + ] = None + label_scale: Annotated[float | None, IOAttrs('ls', store_default=False)] = ( + None + ) + label_is_lstr: Annotated[bool, IOAttrs('ll', store_default=False)] = False + texture: Annotated[str | None, IOAttrs('tex', store_default=False)] = None + scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 + padding_left: Annotated[float, IOAttrs('pl', store_default=False)] = 0.0 + padding_top: Annotated[float, IOAttrs('pt', store_default=False)] = 0.0 + padding_right: Annotated[float, IOAttrs('pr', store_default=False)] = 0.0 + padding_bottom: Annotated[float, IOAttrs('pb', store_default=False)] = 0.0 + decorations: Annotated[ + list[Decoration] | None, IOAttrs('c', store_default=False) + ] = None + style: Annotated[ButtonStyle, IOAttrs('y', store_default=False)] = ( + ButtonStyle.SQUARE + ) + default: Annotated[bool, IOAttrs('df', store_default=False)] = False + selected: Annotated[bool, IOAttrs('sel', store_default=False)] = False + + icon: Annotated[str | None, IOAttrs('icn', store_default=False)] = None + icon_scale: Annotated[float | None, IOAttrs('is', store_default=False)] = ( + None + ) + icon_color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('ic', store_default=False), + ] = None + depth_range: Annotated[ + tuple[float, float] | None, IOAttrs('z', store_default=None) + ] = None + + #: Custom widget id. Will be prefixed with window id, but must be + #: unique within the window. + widget_id: Annotated[str | None, IOAttrs('i', store_default=False)] = None + + #: Draw bounds of the button. + debug: Annotated[bool, IOAttrs('d', store_default=False)] = False + + +class RowTypeID(Enum): + """Type ID for each of our subclasses.""" + + BUTTON_ROW = 'b' + UNKNOWN = 'u' + + +class Row(IOMultiType[RowTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> RowTypeID: + # Require child classes to supply this themselves. If we did a + # full type registry/lookup here it would require us to import + # everything and would prevent lazy loading. + raise NotImplementedError() + + @override + @classmethod + def get_type(cls, type_id: RowTypeID) -> type[Row]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = RowTypeID + if type_id is t.UNKNOWN: + return UnknownRow + if type_id is t.BUTTON_ROW: + return ButtonRow + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> Row: + # If we encounter some future type we don't know anything about, + # drop in a placeholder. + return UnknownRow() + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return '_t' + + +@ioprepped +@dataclass +class UnknownRow(Row): + """A row type we don't have.""" + + @override + @classmethod + def get_type_id(cls) -> RowTypeID: + return RowTypeID.UNKNOWN + + +@ioprepped +@dataclass +class ButtonRow(Row): + """A row consisting of buttons.""" + + buttons: Annotated[list[Button], IOAttrs('b')] + + header_height: Annotated[float, IOAttrs('h', store_default=False)] = 0.0 + header_scale: Annotated[float, IOAttrs('hs', store_default=False)] = 1.0 + header_decorations_left: Annotated[ + list[Decoration] | None, IOAttrs('hdl', store_default=False) + ] = None + header_decorations_center: Annotated[ + list[Decoration] | None, IOAttrs('hdc', store_default=False) + ] = None + header_decorations_right: Annotated[ + list[Decoration] | None, IOAttrs('hdr', store_default=False) + ] = None + + #: Note that doc-ui accepts only raw :class:`str` values for text; + #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language + #: support. + title: Annotated[str | None, IOAttrs('t', store_default=False)] = None + title_color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('tc', store_default=False), + ] = None + title_flatness: Annotated[ + float | None, IOAttrs('tf', store_default=False) + ] = None + title_shadow: Annotated[ + float | None, IOAttrs('ts', store_default=False) + ] = None + title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + subtitle: Annotated[str | None, IOAttrs('s', store_default=False)] = None + subtitle_color: Annotated[ + tuple[float, float, float, float] | None, + IOAttrs('sc', store_default=False), + ] = None + subtitle_flatness: Annotated[ + float | None, IOAttrs('sf', store_default=False) + ] = None + subtitle_shadow: Annotated[ + float | None, IOAttrs('ss', store_default=False) + ] = None + subtitle_is_lstr: Annotated[bool, IOAttrs('sl', store_default=False)] = ( + False + ) + + #: Spacing between all buttons in the row. + button_spacing: Annotated[float, IOAttrs('bs', store_default=False)] = 15.0 + + #: Padding on the left of the row's horizonally-scrollable area. + padding_left: Annotated[float, IOAttrs('pl', store_default=False)] = 10.0 + #: Padding on the right of the row's horizonally-scrollable area. + padding_right: Annotated[float, IOAttrs('pr', store_default=False)] = 10.0 + #: Padding on the top of the row's horizonally-scrollable area. + padding_top: Annotated[float, IOAttrs('pt', store_default=False)] = 10.0 + #: Padding on the bottom of the row's horizonally-scrollable area. + padding_bottom: Annotated[float, IOAttrs('pb', store_default=False)] = 10.0 + + #: Extra space above the row's horizontally-scrollable area. + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + + #: Extra space below the row's horizontally-scrollable area. + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + center_content: Annotated[bool, IOAttrs('c', store_default=False)] = False + center_title: Annotated[bool, IOAttrs('ct', store_default=False)] = False + + #: If things disappear when scrolling left/right, turn this up. + simple_culling_h: Annotated[float, IOAttrs('sch', store_default=False)] = ( + 100.0 + ) + + #: Draw bounds of the overall row and individual button columns + #: (including padding). The UI will scroll to keep these areas + #: visible in their entirety when changing selection via directional + #: controls, so try to make sure all decorations for a button are + #: within these bounds. + debug: Annotated[bool, IOAttrs('d', store_default=False)] = False + + @override + @classmethod + def get_type_id(cls) -> RowTypeID: + return RowTypeID.BUTTON_ROW + + +@ioprepped +@dataclass +class Page: + """Doc-UI page version 1.""" + + #: Note that doc-ui accepts only raw :class:`str` values for text; + #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language + #: support. + title: Annotated[str, IOAttrs('t')] + rows: Annotated[list[Row], IOAttrs('r')] + + #: If True, content smaller than the available height will be + #: centered vertically. This can look natural for certain types of + #: content such as confirmation dialogs. + center_vertically: Annotated[bool, IOAttrs('cv', store_default=False)] = ( + False + ) + + row_spacing: Annotated[float, IOAttrs('s', store_default=False)] = 10.0 + + #: If things disappear when scrolling up and down, turn this up. + simple_culling_v: Annotated[float, IOAttrs('scv', store_default=False)] = ( + 100.0 + ) + + #: Whether the title is a json dict representing an Lstr. Generally + #: doc-ui translation should be handled server-side, but this can + #: allow client-side translation. + title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + + padding_bottom: Annotated[float, IOAttrs('pb', store_default=False)] = 0.0 + padding_left: Annotated[float, IOAttrs('pl', store_default=False)] = 0.0 + padding_top: Annotated[float, IOAttrs('pt', store_default=False)] = 0.0 + padding_right: Annotated[float, IOAttrs('pr', store_default=False)] = 0.0 + + +class ResponseStatus(Enum): + """The overall result of a request.""" + + SUCCESS = 0 + + #: Something went wrong. That's all we know. + UNKNOWN_ERROR = 1 + + #: Something went wrong talking to the server. A 'Retry' button may + #: be appropriate to show here (for GET requests at least). + COMMUNICATION_ERROR = 2 + + #: This requires the user to be signed in, and they aint. + NOT_SIGNED_IN_ERROR = 3 + + +@ioprepped +@dataclass +class Response(DocUIResponse): + """Full docui response.""" + + page: Annotated[Page, IOAttrs('p')] + status: Annotated[ResponseStatus, IOAttrs('s', store_default=False)] = ( + ResponseStatus.SUCCESS + ) + + #: Effects to run on the client when this response is initially + #: received. Note that these effects will not re-run if the page is + #: automatically refreshed later (due to window resizing, back + #: navigation, etc). + #: + #: :meta private: + client_effects: Annotated[ + list[clfx.Effect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + #: Local action to run after this response is initially received. + #: Will be handled by + #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. Note that + #: these actions will not re-run if the page is automatically + #: refreshed later (due to window resizing, back navigation, etc). + local_action: Annotated[str | None, IOAttrs('a', store_default=False)] = ( + None + ) + local_action_args: Annotated[ + dict | None, IOAttrs('aa', store_default=False) + ] = None + + #: New overall action to have the client schedule after this + #: response is received. Useful for redirecting to other pages or + #: closing the doc-ui window. + timed_action: Annotated[ + Action | None, IOAttrs('ta', store_default=False) + ] = None + timed_action_delay: Annotated[ + float, IOAttrs('tad', store_default=False) + ] = 0.0 + + #: If provided, error on builds older than this (can be used to gate + #: functionality without bumping entire docui version). + minimum_engine_build: Annotated[ + int | None, IOAttrs('b', store_default=False) + ] = None + + #: The client maintains some persistent state (such as widget + #: selection) for all pages viewed. The default index for these + #: states is the path of the request. If a server returns a + #: significant variety of responses for a single path, however, + #: (based on args, etc) then it may make sense for the server to + #: provide explicit state ids for those different variations. + shared_state_id: Annotated[ + str | None, IOAttrs('t', store_default=False) + ] = None + + @override + @classmethod + def get_type_id(cls) -> DocUIResponseTypeID: + return DocUIResponseTypeID.V1 diff --git a/dist/ba_data/python/bacommon/locale.py b/dist/ba_data/python/bacommon/locale.py index e6d9bb3..5615f08 100644 --- a/dist/ba_data/python/bacommon/locale.py +++ b/dist/ba_data/python/bacommon/locale.py @@ -71,6 +71,7 @@ class Locale(Enum): VENETIAN = 'venetn' VIETNAMESE = 'viet' KAZAKH = 'kazk' + JAPANESE = 'jpn' # Note: We use if-statement chains here so we can use assert_never() # to ensure we cover all existing values. But we cache lookups so @@ -87,7 +88,7 @@ class Locale(Enum): # pylint: disable=too-many-branches # pylint: disable=too-many-return-statements - cls = type(self) + cls = Locale if self is cls.ENGLISH: return 'English' @@ -175,6 +176,8 @@ class Locale(Enum): return 'Vietnamese' if self is cls.KAZAKH: return 'Kazakh' + if self is cls.JAPANESE: + return 'Japanese' # Make sure we've covered all cases. assert_never(self) @@ -206,7 +209,7 @@ class Locale(Enum): # pylint: disable=too-many-branches # pylint: disable=too-many-return-statements - cls = type(self) + cls = Locale if self is cls.ENGLISH: return 'English' @@ -296,6 +299,8 @@ class Locale(Enum): return 'Vietnamese' if self is cls.KAZAKH: return 'Kazakh' + if self is cls.JAPANESE: + return 'Japanese' # Make sure we've covered all cases. assert_never(self) @@ -306,7 +311,7 @@ class Locale(Enum): # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches - cls = type(self) + cls = Locale R = LocaleResolved if self is cls.ENGLISH: @@ -389,6 +394,8 @@ class Locale(Enum): return R.VIETNAMESE if self is cls.KAZAKH: return R.KAZAKH + if self is cls.JAPANESE: + return R.JAPANESE # Make sure we're covering all cases. assert_never(self) @@ -444,6 +451,7 @@ class LocaleResolved(Enum): VENETIAN = 'venetn' VIETNAMESE = 'viet' KAZAKH = 'kazk' + JAPANESE = 'jpn' # Note: We use if-statement chains here so we can use assert_never() # to ensure we cover all existing values. But we cache lookups so @@ -464,7 +472,7 @@ class LocaleResolved(Enum): # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches - cls = type(self) + cls = LocaleResolved if self is cls.ENGLISH: return Locale.ENGLISH @@ -546,6 +554,8 @@ class LocaleResolved(Enum): return Locale.VIETNAMESE if self is cls.KAZAKH: return Locale.KAZAKH + if self is cls.JAPANESE: + return Locale.JAPANESE # Make sure we're covering all cases. assert_never(self) @@ -561,7 +571,7 @@ class LocaleResolved(Enum): """ # pylint: disable=too-many-branches # pylint: disable=too-many-statements - cls = type(self) + cls = LocaleResolved val: str | None = None @@ -647,6 +657,8 @@ class LocaleResolved(Enum): val = 'vi' elif self is cls.KAZAKH: val = 'kk' + elif self is cls.JAPANESE: + val = 'ja' else: # Make sure we cover all cases. assert_never(self) @@ -668,9 +680,9 @@ class LocaleResolved(Enum): return val - @classmethod + @staticmethod @lru_cache(maxsize=128) - def from_tag(cls, tag: str) -> LocaleResolved: + def from_tag(tag: str) -> LocaleResolved: """Return a locale for a given string tag. Tags can be provided in BCP 47 form ('en-US') or POSIX locale @@ -680,6 +692,8 @@ class LocaleResolved(Enum): # pylint: disable=too-many-statements # pylint: disable=too-many-return-statements + cls = LocaleResolved + # POSIX locale strings can contain a dot followed by an # encoding. Strip that off. tag2 = tag.split('.')[0] @@ -838,6 +852,8 @@ class LocaleResolved(Enum): return cls.VIETNAMESE if lang == 'kk': return cls.KAZAKH + if lang == 'ja': + return cls.JAPANESE # Make noise if we come across something unexpected so we can # add it. diff --git a/dist/ba_data/python/bacommon/loggercontrol.py b/dist/ba_data/python/bacommon/loggercontrol.py index e07e811..17c9b59 100644 --- a/dist/ba_data/python/bacommon/loggercontrol.py +++ b/dist/ba_data/python/bacommon/loggercontrol.py @@ -108,6 +108,11 @@ class LoggerControlConfig: for logname in existinglognames: logger = logging.getLogger(logname) if logger.getEffectiveLevel() != self.get_effective_level(logname): + + # Exceptions for ones that I don't care to look into. + if logname in {'pyasn1'}: + continue + logging.error( 'loggercontrol effective-level sanity check failed;' ' expected logger %s to have effective level %s' diff --git a/dist/ba_data/python/bacommon/logging.py b/dist/ba_data/python/bacommon/logging.py index 2b22fb0..f115fdd 100644 --- a/dist/ba_data/python/bacommon/logging.py +++ b/dist/ba_data/python/bacommon/logging.py @@ -48,7 +48,7 @@ class ClientLoggerName(Enum): """Return a short description for the logger.""" # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches - cls = type(self) + cls = ClientLoggerName if self is cls.BA: return 'top level Ballistica logger - use to adjust everything' if self is cls.ENV: diff --git a/dist/ba_data/python/bacommon/login.py b/dist/ba_data/python/bacommon/login.py index 7fc3729..d7efb01 100644 --- a/dist/ba_data/python/bacommon/login.py +++ b/dist/ba_data/python/bacommon/login.py @@ -32,7 +32,7 @@ class LoginType(Enum): @property def displayname(self) -> str: """A human readable name for this value.""" - cls = type(self) + cls = LoginType match self: case cls.EMAIL: return 'Email/Password' @@ -44,7 +44,7 @@ class LoginType(Enum): @property def displaynameshort(self) -> str: """A short human readable name for this value.""" - cls = type(self) + cls = LoginType match self: case cls.EMAIL: return 'Email' diff --git a/dist/ba_data/python/bacommon/net.py b/dist/ba_data/python/bacommon/net.py index 36596ed..8d60817 100644 --- a/dist/ba_data/python/bacommon/net.py +++ b/dist/ba_data/python/bacommon/net.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Network related data and functionality.""" +"""Network related data and functionality. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations diff --git a/dist/ba_data/python/bacommon/securedata.py b/dist/ba_data/python/bacommon/securedata.py index 2b4d740..4e29856 100644 --- a/dist/ba_data/python/bacommon/securedata.py +++ b/dist/ba_data/python/bacommon/securedata.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to verifying ballistica server generated data.""" +"""Functionality related to verifying server generated data. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" import datetime from dataclasses import dataclass diff --git a/dist/ba_data/python/bacommon/servermanager.py b/dist/ba_data/python/bacommon/servermanager.py index 9a0f8d9..e053098 100644 --- a/dist/ba_data/python/bacommon/servermanager.py +++ b/dist/ba_data/python/bacommon/servermanager.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to the server manager script.""" + from __future__ import annotations from enum import Enum @@ -29,13 +30,19 @@ class ServerConfig: # If True, all connecting clients will be authenticated through the # master server to screen for fake account info. Generally this # should always be enabled unless you are hosting on a LAN with no - # internet connection. + # internet connection. Note that if you set protocol_version to 36 + # or newer, client authentication uses V2 account info. This is + # highly recommended as it does not have spoofing vulnerabilities + # like the earlier V1 authentication. authenticate_clients: bool = True # IDs of server admins. Server admins are not kickable through the # default kick vote system and they are able to kick players without - # a vote. To get your account id, enter 'getaccountid' in - # settings->advanced->enter-code. + # a vote. If protocol_version is set to 36 or newer this will use V2 + # account ids (a-XXX); otherwise it will use V1 ids (pb-XXX). To get + # your V2 account id, poke the 'manage account' button in the + # account window in-game. To get your V1 account id, enter + # 'getaccountid' in Settings->Advanced->Send Info admins: list[str] = field(default_factory=list) # Whether the default kick-voting system is enabled. @@ -175,7 +182,10 @@ class ServerConfig: # Protocol version we host with. Currently the default is 33 which # still allows older 1.4 game clients to connect. Explicitly setting # to 35 no longer allows those clients but adds/fixes a few things - # such as making camera shake properly work in net games. + # such as making camera shake properly work in net games. Protocol + # 36 enables V2 account ids (a-XXX) for client authentication, which + # does not suffer from spoofing vulnerabilities that V1 account ids + # (pb-XXX) did. protocol_version: int | None = None # (internal) stress-testing mode. diff --git a/dist/ba_data/python/bacommon/text.py b/dist/ba_data/python/bacommon/text.py new file mode 100644 index 0000000..84e86ef --- /dev/null +++ b/dist/ba_data/python/bacommon/text.py @@ -0,0 +1,121 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Text related bits.""" + +from __future__ import annotations +from enum import Enum + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + + +class SpecialChar(Enum): + """Custom unicode characters the engine can display. + + Keep this in sync with babase._mgen.enums.SpecialChar. + """ + + LEFT_ARROW = '\ue001' + RIGHT_ARROW = '\ue002' + UP_ARROW = '\ue003' + DOWN_ARROW = '\ue004' + LEFT_BUTTON = '\ue005' + TOP_BUTTON = '\ue006' + RIGHT_BUTTON = '\ue007' + BOTTOM_BUTTON = '\ue008' + DELETE = '\ue009' + SHIFT = '\ue00a' + BACK = '\ue00b' + LOGO_FLAT = '\ue00c' + REWIND_BUTTON = '\ue00d' + PLAY_PAUSE_BUTTON = '\ue00e' + FAST_FORWARD_BUTTON = '\ue00f' + DPAD_CENTER_BUTTON = '\ue010' + PLAY_STATION_CROSS_BUTTON = '\ue011' + PLAY_STATION_CIRCLE_BUTTON = '\ue012' + PLAY_STATION_TRIANGLE_BUTTON = '\ue013' + PLAY_STATION_SQUARE_BUTTON = '\ue014' + PLAY_BUTTON = '\ue015' + PAUSE_BUTTON = '\ue016' + CLOSE = '\ue017' + OUYA_BUTTON_O = '\ue019' + OUYA_BUTTON_U = '\ue01a' + OUYA_BUTTON_Y = '\ue01b' + OUYA_BUTTON_A = '\ue01c' + TOKEN = '\ue01d' + LOGO = '\ue01e' + TICKET = '\ue01f' + GOOGLE_PLAY_GAMES_LOGO = '\ue020' + GAME_CENTER_LOGO = '\ue021' + DICE_BUTTON1 = '\ue022' + DICE_BUTTON2 = '\ue023' + DICE_BUTTON3 = '\ue024' + DICE_BUTTON4 = '\ue025' + GAME_CIRCLE_LOGO = '\ue026' + PARTY_ICON = '\ue027' + TEST_ACCOUNT = '\ue028' + TICKET_BACKING = '\ue029' + TROPHY1 = '\ue02a' + TROPHY2 = '\ue02b' + TROPHY3 = '\ue02c' + TROPHY0A = '\ue02d' + TROPHY0B = '\ue02e' + TROPHY4 = '\ue02f' + LOCAL_ACCOUNT = '\ue030' + EXPLODINARY_LOGO = '\ue031' + FLAG_UNITED_STATES = '\ue032' + FLAG_MEXICO = '\ue033' + FLAG_GERMANY = '\ue034' + FLAG_BRAZIL = '\ue035' + FLAG_RUSSIA = '\ue036' + FLAG_CHINA = '\ue037' + FLAG_UNITED_KINGDOM = '\ue038' + FLAG_CANADA = '\ue039' + FLAG_INDIA = '\ue03a' + FLAG_JAPAN = '\ue03b' + FLAG_FRANCE = '\ue03c' + FLAG_INDONESIA = '\ue03d' + FLAG_ITALY = '\ue03e' + FLAG_SOUTH_KOREA = '\ue03f' + FLAG_NETHERLANDS = '\ue040' + FEDORA = '\ue041' + HAL = '\ue042' + CROWN = '\ue043' + YIN_YANG = '\ue044' + EYE_BALL = '\ue045' + SKULL = '\ue046' + HEART = '\ue047' + DRAGON = '\ue048' + HELMET = '\ue049' + MUSHROOM = '\ue04a' + NINJA_STAR = '\ue04b' + VIKING_HELMET = '\ue04c' + MOON = '\ue04d' + SPIDER = '\ue04e' + FIREBALL = '\ue04f' + FLAG_UNITED_ARAB_EMIRATES = '\ue050' + FLAG_QATAR = '\ue051' + FLAG_EGYPT = '\ue052' + FLAG_KUWAIT = '\ue053' + FLAG_ALGERIA = '\ue054' + FLAG_SAUDI_ARABIA = '\ue055' + FLAG_MALAYSIA = '\ue056' + FLAG_CZECH_REPUBLIC = '\ue057' + FLAG_AUSTRALIA = '\ue058' + FLAG_SINGAPORE = '\ue059' + OCULUS_LOGO = '\ue05a' + STEAM_LOGO = '\ue05b' + NVIDIA_LOGO = '\ue05c' + FLAG_IRAN = '\ue05d' + FLAG_POLAND = '\ue05e' + FLAG_ARGENTINA = '\ue05f' + FLAG_PHILIPPINES = '\ue060' + FLAG_CHILE = '\ue061' + MIKIROG = '\ue062' + V2_LOGO = '\ue063' + SANTA_HAT = '\ue064' + POTATO = '\ue065' + PALM_TREE = '\ue066' + BOXING_GLOVE = '\ue067' diff --git a/dist/ba_data/python/bacommon/transfer.py b/dist/ba_data/python/bacommon/transfer.py index aa8d38d..67f1ac7 100644 --- a/dist/ba_data/python/bacommon/transfer.py +++ b/dist/ba_data/python/bacommon/transfer.py @@ -1,6 +1,12 @@ # Released under the MIT License. See LICENSE for details. # -"""Functionality related to transferring files/data.""" +"""Functionality related to transferring files/data. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" from __future__ import annotations diff --git a/dist/ba_data/python/bacommon/workspace/assetsv1.py b/dist/ba_data/python/bacommon/workspace/assetsv1.py index 83a058a..fa6f7ba 100644 --- a/dist/ba_data/python/bacommon/workspace/assetsv1.py +++ b/dist/ba_data/python/bacommon/workspace/assetsv1.py @@ -2,10 +2,10 @@ # """Public types for assets-v1 workspaces. -These types may only be used server-side, but they are exposed here -for reference when setting workspace config data by hand or for use -in client-side workspace modification tools. There may be advanced -settings that are not accessible through the UI/etc. +While this module is currently only used server-side, its source code +can be useful as reference when setting workspace config data by hand or +for use in client-side workspace modification tools. There may be +advanced settings that are not accessible through the UI/etc. """ from __future__ import annotations @@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Annotated, override, assert_never from efro.dataclassio import ioprepped, IOAttrs, IOMultiType from bacommon.locale import Locale - if TYPE_CHECKING: pass @@ -85,8 +84,8 @@ class AssetsV1StringFileV1(AssetsV1StringFile): NONE = 'none' TITLE = 'title' - INTENSE = 'intense' - SUBTLE = 'subtle' + LOUD = 'loud' + SOFT = 'soft' @override @classmethod @@ -121,7 +120,7 @@ class AssetsV1PathValsTypeID(Enum): """Types of vals we can store for paths.""" TEX_V1 = 'tex_v1' - # STR_V1 = 'str_v1' + STR_V1 = 'str_v1' class AssetsV1PathVals(IOMultiType[AssetsV1PathValsTypeID]): @@ -151,6 +150,9 @@ class AssetsV1PathVals(IOMultiType[AssetsV1PathValsTypeID]): if type_id is t.TEX_V1: return AssetsV1PathValsTexV1 + if type_id is t.STR_V1: + return AssetsV1PathValsStrV1 + # Important to make sure we provide all types. assert_never(type_id) @@ -176,3 +178,20 @@ class AssetsV1PathValsTexV1(AssetsV1PathVals): @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: return AssetsV1PathValsTypeID.TEX_V1 + + +@ioprepped +@dataclass +class AssetsV1PathValsStrV1(AssetsV1PathVals): + """Path-specific values for an assets_v1 workspace path.""" + + #: Hash generated when all translations for this entry are complete. + #: Used as a fast-out for checking whether updates are needed. + up_to_date_state: Annotated[ + str | None, IOAttrs('up_to_date_state', store_default=False) + ] = None + + @override + @classmethod + def get_type_id(cls) -> AssetsV1PathValsTypeID: + return AssetsV1PathValsTypeID.STR_V1 diff --git a/dist/ba_data/python/baenv.py b/dist/ba_data/python/baenv.py index 84f0471..a9995ce 100644 --- a/dist/ba_data/python/baenv.py +++ b/dist/ba_data/python/baenv.py @@ -14,6 +14,7 @@ Ballistica can be used without explicitly configuring the environment in order to integrate it in arbitrary Python environments, but this may cause some features to be disabled or behave differently than expected. """ + from __future__ import annotations import os @@ -56,8 +57,8 @@ logger = logging.getLogger('ba.env') # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22584 -TARGET_BALLISTICA_VERSION = '1.7.53' +TARGET_BALLISTICA_BUILD = 22714 +TARGET_BALLISTICA_VERSION = '1.7.61' @dataclass @@ -95,7 +96,7 @@ class EnvConfig: #: stderr into the engine so they show up on in-app consoles, etc. log_handler: LogHandler | None - # Initial data from the ``config.json`` file in the config dir. + #: Initial data from the ``config.json`` file in the config dir. initial_app_config: Any #: Timestamp when we first started doing stuff. diff --git a/dist/ba_data/python/baplus/_ads.py b/dist/ba_data/python/baplus/_ads.py index f6aec55..4b77cbb 100644 --- a/dist/ba_data/python/baplus/_ads.py +++ b/dist/ba_data/python/baplus/_ads.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to ads.""" + from __future__ import annotations import time diff --git a/dist/ba_data/python/baplus/_appsubsystem.py b/dist/ba_data/python/baplus/_appsubsystem.py index 507d172..29862e2 100644 --- a/dist/ba_data/python/baplus/_appsubsystem.py +++ b/dist/ba_data/python/baplus/_appsubsystem.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides plus app subsystem.""" + from __future__ import annotations from typing import TYPE_CHECKING, override @@ -13,7 +14,7 @@ from baplus._ads import AdsSubsystem if TYPE_CHECKING: from typing import Callable, Any - import bacommon.bs + import bacommon.classic from babase import AccountV2Subsystem from baplus._cloud import CloudSubsystem @@ -142,14 +143,6 @@ class PlusAppSubsystem(AppSubsystem): """:meta private:""" return _baplus.get_v1_account_state_num() - # @staticmethod - # def get_v1_account_ticket_count() -> int: - # """Return the number of tickets for the current account. - - # :meta private: - # """ - # return _baplus.get_v1_account_ticket_count() - @staticmethod def get_v1_account_type() -> str: """:meta private:""" diff --git a/dist/ba_data/python/baplus/_cloud.py b/dist/ba_data/python/baplus/_cloud.py index f1f4097..bdf2e0a 100644 --- a/dist/ba_data/python/baplus/_cloud.py +++ b/dist/ba_data/python/baplus/_cloud.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, overload from efro.error import CommunicationError from efro.call import CallbackSet from efro.dataclassio import dataclass_from_dict, dataclass_to_dict -import bacommon.bs +import bacommon.classic import bacommon.cloud import babase @@ -19,7 +19,8 @@ if TYPE_CHECKING: from typing import Callable, Any from efro.message import Message, Response, BoolResponse - import bacommon.bs + import bacommon.classic + import bacommon.clouddialog as cdlg # TODO: Should make it possible to define a protocol in bacommon.cloud and @@ -36,6 +37,8 @@ class CloudSubsystem(babase.AppSubsystem): """ #: General engine config values provided by the cloud. + #: + #: :meta private: vals: bacommon.cloud.CloudVals def __init__(self) -> None: @@ -214,9 +217,9 @@ class CloudSubsystem(babase.AppSubsystem): @overload def send_message_cb( self, - msg: bacommon.bs.GetClassicPurchasesMessage, + msg: bacommon.classic.GetClassicPurchasesMessage, on_response: Callable[ - [bacommon.bs.GetClassicPurchasesResponse | Exception], None + [bacommon.classic.GetClassicPurchasesResponse | Exception], None ], ) -> None: ... @@ -232,61 +235,59 @@ class CloudSubsystem(babase.AppSubsystem): @overload def send_message_cb( self, - msg: bacommon.bs.PrivatePartyMessage, + msg: bacommon.classic.PrivatePartyMessage, on_response: Callable[ - [bacommon.bs.PrivatePartyResponse | Exception], None + [bacommon.classic.PrivatePartyResponse | Exception], None ], ) -> None: ... @overload def send_message_cb( self, - msg: bacommon.bs.InboxRequestMessage, + msg: bacommon.classic.InboxRequestMessage, on_response: Callable[ - [bacommon.bs.InboxRequestResponse | Exception], None + [bacommon.classic.InboxRequestResponse | Exception], None ], ) -> None: ... @overload def send_message_cb( self, - msg: bacommon.bs.CloudDialogActionMessage, + msg: cdlg.ActionMessage, + on_response: Callable[[cdlg.ActionResponse | Exception], None], + ) -> None: ... + + @overload + def send_message_cb( + self, + msg: bacommon.classic.ChestInfoMessage, on_response: Callable[ - [bacommon.bs.CloudDialogActionResponse | Exception], None + [bacommon.classic.ChestInfoResponse | Exception], None ], ) -> None: ... @overload def send_message_cb( self, - msg: bacommon.bs.ChestInfoMessage, + msg: bacommon.cloud.ChestActionMessage, on_response: Callable[ - [bacommon.bs.ChestInfoResponse | Exception], None + [bacommon.cloud.ChestActionResponse | Exception], None ], ) -> None: ... @overload def send_message_cb( self, - msg: bacommon.bs.ChestActionMessage, - on_response: Callable[ - [bacommon.bs.ChestActionResponse | Exception], None - ], - ) -> None: ... - - @overload - def send_message_cb( - self, - msg: bacommon.bs.GlobalProfileCheckMessage, + msg: bacommon.classic.GlobalProfileCheckMessage, on_response: Callable[[BoolResponse | Exception], None], ) -> None: ... @overload def send_message_cb( self, - msg: bacommon.bs.ScoreSubmitMessage, + msg: bacommon.classic.ScoreSubmitMessage, on_response: Callable[ - [bacommon.bs.ScoreSubmitResponse | Exception], None + [bacommon.classic.ScoreSubmitResponse | Exception], None ], ) -> None: ... @@ -308,6 +309,39 @@ class CloudSubsystem(babase.AppSubsystem): ], ) -> None: ... + @overload + def send_message_cb( + self, + msg: bacommon.classic.GetClassicLeaguePresidentButtonInfoMessage, + on_response: Callable[ + [ + bacommon.classic.GetClassicLeaguePresidentButtonInfoResponse + | Exception + ], + None, + ], + ) -> None: ... + + @overload + def send_message_cb( + self, + msg: bacommon.cloud.AnalyticsEventMessage, + on_response: Callable[ + [None | Exception], + None, + ], + ) -> None: ... + + @overload + def send_message_cb( + self, + msg: bacommon.cloud.AuthRequestMessage, + on_response: Callable[ + [bacommon.cloud.AuthRequestResponse | Exception], + None, + ], + ) -> None: ... + def send_message_cb( self, msg: Message, @@ -339,8 +373,13 @@ class CloudSubsystem(babase.AppSubsystem): @overload def send_message( - self, msg: bacommon.bs.LegacyRequest - ) -> bacommon.bs.LegacyResponse: ... + self, msg: bacommon.classic.LegacyRequest + ) -> bacommon.classic.LegacyResponse: ... + + @overload + def send_message( + self, msg: bacommon.cloud.FulfillDocUIRequest + ) -> bacommon.cloud.FulfillDocUIResponse: ... def send_message(self, msg: Message) -> Response | None: """Synchronously send a message to the cloud. @@ -353,8 +392,8 @@ class CloudSubsystem(babase.AppSubsystem): @overload async def send_message_async( - self, msg: bacommon.bs.SendInfoMessage - ) -> bacommon.bs.SendInfoResponse: ... + self, msg: bacommon.classic.SendInfoMessage + ) -> bacommon.classic.SendInfoResponse: ... @overload async def send_message_async( @@ -383,9 +422,14 @@ class CloudSubsystem(babase.AppSubsystem): def subscribe_classic_account_data( self, - updatecall: Callable[[bacommon.bs.ClassicAccountLiveData], None], + updatecall: Callable[ + [bacommon.classic.ClassicLiveAccountClientData], None + ], ) -> babase.CloudSubscription: - """Subscribe to classic account data.""" + """Subscribe to classic account data. + + :meta private: + """ raise NotImplementedError( 'Cloud functionality is not present in this build.' ) diff --git a/dist/ba_data/python/baplus/_hooks.py b/dist/ba_data/python/baplus/_hooks.py index 0348914..95b43fe 100644 --- a/dist/ba_data/python/baplus/_hooks.py +++ b/dist/ba_data/python/baplus/_hooks.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Snippets of code for use by the c++ layer.""" + # (most of these are self-explanatory) # pylint: disable=missing-function-docstring from __future__ import annotations diff --git a/dist/ba_data/python/bascenev1/__init__.py b/dist/ba_data/python/bascenev1/__init__.py index f739b07..aead594 100644 --- a/dist/ba_data/python/bascenev1/__init__.py +++ b/dist/ba_data/python/bascenev1/__init__.py @@ -16,7 +16,6 @@ import logging # other modules; the goal is to let most simple mods rely solely on this # module to keep things simple. -# from efro.util import set_canonical_module_names from babase import ( ActivityNotFoundError, add_clean_frame_callback, @@ -32,6 +31,8 @@ from babase import ( apptimer, AppTimer, Call, + CallPartial, + CallStrict, ContextError, ContextRef, displaytime, @@ -63,6 +64,8 @@ from babase import ( unlock_all_input, Vec3, WeakCall, + WeakCallPartial, + WeakCallStrict, ) from _bascenev1 import ( @@ -275,6 +278,8 @@ __all__ = [ 'BaseTimer', 'BoolSetting', 'Call', + 'CallPartial', + 'CallStrict', 'cameraflash', 'camerashake', 'Campaign', @@ -475,15 +480,11 @@ __all__ = [ 'unlock_all_input', 'Vec3', 'WeakCall', + 'WeakCallPartial', + 'WeakCallStrict', 'WinnerGroup', ] -# We want stuff here to show up as bascenev1.Foo instead of -# bascenev1._submodule.Foo. -# UPDATE: Trying without this for now. Seems like this might cause more -# harm than good. Can flip it back on if it is missed. -# set_canonical_module_names(globals()) - # Sanity check: we want to keep ballistica's dependencies and # bootstrapping order clearly defined; let's check a few particular # modules to make sure they never directly or indirectly import us diff --git a/dist/ba_data/python/bascenev1/_activity.py b/dist/ba_data/python/bascenev1/_activity.py index c78f1a6..57aaa12 100644 --- a/dist/ba_data/python/bascenev1/_activity.py +++ b/dist/ba_data/python/bascenev1/_activity.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Defines Activity class.""" + from __future__ import annotations import weakref @@ -12,9 +13,8 @@ import _bascenev1 from bascenev1._dependency import DependencyComponent from bascenev1._messages import UNHANDLED - if TYPE_CHECKING: - from typing import Any + from typing import Any, Self import bascenev1 @@ -192,7 +192,7 @@ class Activity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( session = self._session() if session is not None: babase.pushcall( - babase.Call( + babase.CallStrict( session.transitioning_out_activity_was_freed, self.can_show_ad_on_death, ) @@ -286,7 +286,7 @@ class Activity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( ref = weakref.ref(self) self._activity_death_check_timer = babase.AppTimer( 5.0, - babase.Call(self._check_activity_death, ref, [0]), + babase.CallStrict(self._check_activity_death, ref, [0]), repeat=True, ) @@ -722,7 +722,7 @@ class Activity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( @classmethod def _check_activity_death( - cls, activity_ref: weakref.ref[Activity], counter: list[int] + cls, activity_ref: weakref.ref[Self], counter: list[int] ) -> None: """Sanity check to make sure an Activity was destroyed properly. diff --git a/dist/ba_data/python/bascenev1/_activitytypes.py b/dist/ba_data/python/bascenev1/_activitytypes.py index c80941a..fe05362 100644 --- a/dist/ba_data/python/bascenev1/_activitytypes.py +++ b/dist/ba_data/python/bascenev1/_activitytypes.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Some handy base class and special purpose Activity types.""" + from __future__ import annotations from typing import TYPE_CHECKING, override @@ -14,7 +15,6 @@ from bascenev1._player import EmptyPlayer from bascenev1._team import EmptyTeam from bascenev1._music import MusicType, setmusic - if TYPE_CHECKING: import bascenev1 from bascenev1._lobby import JoinInfo @@ -54,7 +54,7 @@ class EndSessionActivity(Activity[EmptyPlayer, EmptyTeam]): babase.unlock_all_input() assert babase.app.plus is not None - call = babase.Call(_bascenev1.new_host_session, main_menu_session) + call = babase.CallStrict(_bascenev1.new_host_session, main_menu_session) if classic.can_show_interstitial(): plus.ads.call_after_ad(call) else: @@ -172,7 +172,7 @@ class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]): # If we're still kicking at the end of our assign-delay, assign this # guy's input to trigger us. _bascenev1.timer( - time_till_assign, babase.WeakCall(self._safe_assign, player) + time_till_assign, babase.WeakCallStrict(self._safe_assign, player) ) @override diff --git a/dist/ba_data/python/bascenev1/_campaign.py b/dist/ba_data/python/bascenev1/_campaign.py index f45f8bc..10d0de0 100644 --- a/dist/ba_data/python/bascenev1/_campaign.py +++ b/dist/ba_data/python/bascenev1/_campaign.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to co-op campaigns.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/bascenev1/_collision.py b/dist/ba_data/python/bascenev1/_collision.py index 11877f6..5dc418f 100644 --- a/dist/ba_data/python/bascenev1/_collision.py +++ b/dist/ba_data/python/bascenev1/_collision.py @@ -19,7 +19,9 @@ class Collision: @property def position(self) -> bascenev1.Vec3: """The position of the current collision.""" - return babase.Vec3(_bascenev1.get_collision_info('position')) + out = babase.Vec3(_bascenev1.get_collision_info('position')) + assert isinstance(out, babase.Vec3) + return out @property def sourcenode(self) -> bascenev1.Node: @@ -30,7 +32,7 @@ class Collision: start of the collision callback). """ node = _bascenev1.get_collision_info('sourcenode') - assert isinstance(node, (_bascenev1.Node, type(None))) + assert isinstance(node, _bascenev1.Node | None) if not node: raise babase.NodeNotFoundError() return node @@ -45,7 +47,7 @@ class Collision: currently-colliding node. """ node = _bascenev1.get_collision_info('opposingnode') - assert isinstance(node, (_bascenev1.Node, type(None))) + assert isinstance(node, _bascenev1.Node | None) if not node: raise babase.NodeNotFoundError() return node diff --git a/dist/ba_data/python/bascenev1/_coopgame.py b/dist/ba_data/python/bascenev1/_coopgame.py index 56cfb12..7861a29 100644 --- a/dist/ba_data/python/bascenev1/_coopgame.py +++ b/dist/ba_data/python/bascenev1/_coopgame.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to co-op games.""" + from __future__ import annotations import logging @@ -64,11 +65,11 @@ class CoopGameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( if not arcade_or_demo: _bascenev1.timer( - 3.8, babase.WeakCall(self._show_remaining_achievements) + 3.8, babase.WeakCallStrict(self._show_remaining_achievements) ) # Preload achievement images in case we get some. - _bascenev1.timer(2.0, babase.WeakCall(self._preload_achievements)) + _bascenev1.timer(2.0, babase.WeakCallStrict(self._preload_achievements)) # FIXME: this is now redundant with activityutils.getscoreconfig(); # need to kill this. @@ -232,7 +233,7 @@ class CoopGameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( """Set up a beeping noise to play when any players are near death.""" self._life_warning_beep = None self._life_warning_beep_timer = _bascenev1.Timer( - 1.0, babase.WeakCall(self._update_life_warning), repeat=True + 1.0, babase.WeakCallStrict(self._update_life_warning), repeat=True ) def _update_life_warning(self) -> None: diff --git a/dist/ba_data/python/bascenev1/_coopsession.py b/dist/ba_data/python/bascenev1/_coopsession.py index c8b176b..d066115 100644 --- a/dist/ba_data/python/bascenev1/_coopsession.py +++ b/dist/ba_data/python/bascenev1/_coopsession.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to coop-mode sessions.""" + from __future__ import annotations from typing import TYPE_CHECKING, override @@ -185,7 +186,9 @@ class CoopSession(Session): def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None: super().on_player_leave(sessionplayer) - _bascenev1.timer(2.0, babase.WeakCall(self._handle_empty_activity)) + _bascenev1.timer( + 2.0, babase.WeakCallStrict(self._handle_empty_activity) + ) def _handle_empty_activity(self) -> None: """Handle cases where all players have left the current activity.""" @@ -358,7 +361,7 @@ class CoopSession(Session): { 'label': babase.Lstr(resource='restartText'), 'resume_on_call': False, - 'call': babase.WeakCall( + 'call': babase.WeakCallPartial( self._on_tournament_restart_menu_press ), } @@ -367,7 +370,7 @@ class CoopSession(Session): self._custom_menu_ui = [ { 'label': babase.Lstr(resource='restartText'), - 'call': babase.WeakCall(self.restart), + 'call': babase.WeakCallStrict(self.restart), } ] diff --git a/dist/ba_data/python/bascenev1/_debug.py b/dist/ba_data/python/bascenev1/_debug.py index 768b42b..9348635 100644 --- a/dist/ba_data/python/bascenev1/_debug.py +++ b/dist/ba_data/python/bascenev1/_debug.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Debugging functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/bascenev1/_dualteamsession.py b/dist/ba_data/python/bascenev1/_dualteamsession.py index 7ea0a13..8b013b4 100644 --- a/dist/ba_data/python/bascenev1/_dualteamsession.py +++ b/dist/ba_data/python/bascenev1/_dualteamsession.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to teams sessions.""" + from __future__ import annotations from typing import TYPE_CHECKING, override diff --git a/dist/ba_data/python/bascenev1/_gameactivity.py b/dist/ba_data/python/bascenev1/_gameactivity.py index 698fec2..f5cd386 100644 --- a/dist/ba_data/python/bascenev1/_gameactivity.py +++ b/dist/ba_data/python/bascenev1/_gameactivity.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides GameActivity class.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -405,7 +406,9 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( 'tournamentIDs': [tournament_id], 'source': 'in-game time remaining query', }, - callback=babase.WeakCall(self._on_tournament_query_response), + callback=babase.WeakCallPartial( + self._on_tournament_query_response + ), ) def _on_tournament_query_response( @@ -805,7 +808,7 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( player.customdata['respawn_timer'] = _bascenev1.Timer( respawn_time, - babase.WeakCall(self.spawn_player_if_exists, player), + babase.WeakCallStrict(self.spawn_player_if_exists, player), ) player.customdata['respawn_icon'] = RespawnIcon( player, respawn_time @@ -902,7 +905,7 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( self._powerup_drop_timer = _bascenev1.Timer( DEFAULT_POWERUP_INTERVAL, - babase.WeakCall(self._standard_drop_powerups), + babase.WeakCallStrict(self._standard_drop_powerups), repeat=True, ) self._standard_drop_powerups() @@ -927,7 +930,7 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( points = self.map.powerup_spawn_points for i in range(len(points)): _bascenev1.timer( - i * 0.4, babase.WeakCall(self._standard_drop_powerup, i) + i * 0.4, babase.WeakCallStrict(self._standard_drop_powerup, i) ) def _setup_standard_tnt_drops(self) -> None: @@ -953,7 +956,9 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( return self._standard_time_limit_time = int(duration) self._standard_time_limit_timer = _bascenev1.Timer( - 1.0, babase.WeakCall(self._standard_time_limit_tick), repeat=True + 1.0, + babase.WeakCallStrict(self._standard_time_limit_tick), + repeat=True, ) self._standard_time_limit_text = NodeActor( _bascenev1.newnode( @@ -1043,7 +1048,9 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( # then we have to mess with contexts and whatnot since its currently # not available in activity contexts. :-/ self._tournament_time_limit_timer = _bascenev1.BaseTimer( - 1.0, babase.WeakCall(self._tournament_time_limit_tick), repeat=True + 1.0, + babase.WeakCallStrict(self._tournament_time_limit_tick), + repeat=True, ) self._tournament_time_limit_title_text = NodeActor( _bascenev1.newnode( diff --git a/dist/ba_data/python/bascenev1/_gameresults.py b/dist/ba_data/python/bascenev1/_gameresults.py index 795070c..61e5333 100644 --- a/dist/ba_data/python/bascenev1/_gameresults.py +++ b/dist/ba_data/python/bascenev1/_gameresults.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to game results.""" + from __future__ import annotations import copy diff --git a/dist/ba_data/python/bascenev1/_hooks.py b/dist/ba_data/python/bascenev1/_hooks.py index b88bfb6..cde61d8 100644 --- a/dist/ba_data/python/bascenev1/_hooks.py +++ b/dist/ba_data/python/bascenev1/_hooks.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Snippets of code for use by the c++ layer.""" + # (most of these are self-explanatory) # pylint: disable=missing-function-docstring from __future__ import annotations diff --git a/dist/ba_data/python/bascenev1/_level.py b/dist/ba_data/python/bascenev1/_level.py index 0f777dc..c26a0a1 100644 --- a/dist/ba_data/python/bascenev1/_level.py +++ b/dist/ba_data/python/bascenev1/_level.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to individual levels in a campaign.""" + from __future__ import annotations import copy @@ -118,10 +119,11 @@ class Level: def get_high_scores(self) -> dict: """Return the current high scores for this level.""" config = self._get_config_dict() - high_scores_key = 'High Scores' + self.get_score_version_string() - if high_scores_key not in config: - return {} - return copy.deepcopy(config[high_scores_key]) + high_scores_key = f'High Scores{self.get_score_version_string()}' + val = config.get(high_scores_key) + if isinstance(val, dict): + return copy.deepcopy(val) + return {} def set_high_scores(self, high_scores: dict) -> None: """Set high scores for this level.""" diff --git a/dist/ba_data/python/bascenev1/_lobby.py b/dist/ba_data/python/bascenev1/_lobby.py index 99f36c0..69814a5 100644 --- a/dist/ba_data/python/bascenev1/_lobby.py +++ b/dist/ba_data/python/bascenev1/_lobby.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Implements lobby system for gathering before games, char select, etc.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -118,7 +119,7 @@ class JoinInfo: ) self._timer = _bascenev1.Timer( - 4.0, babase.WeakCall(self._update), repeat=True + 4.0, babase.WeakCallStrict(self._update), repeat=True ) def _update_for_keyboard(self, keyboard: bascenev1.InputDevice) -> None: @@ -338,10 +339,10 @@ class Chooser: if inputdevice.is_controller_app and '_random' in profilenames: return profilenames.index('_random') - # If its a client connection, for now just force - # the account profile if possible.. (need to provide a - # way for clients to specify/remember their default - # profile on remote servers that do not already know them). + # If its a client connection, for now just force the account + # profile if possible. (need to provide a way for clients to + # specify/remember their default profile on remote servers that + # do not already know them). if inputdevice.is_remote_client and '__account__' in profilenames: return profilenames.index('__account__') @@ -609,25 +610,29 @@ class Chooser: if not ready: self._sessionplayer.assigninput( babase.InputType.LEFT_PRESS, - babase.Call(self.handlemessage, ChangeMessage('team', -1)), + babase.CallStrict( + self.handlemessage, ChangeMessage('team', -1) + ), ) self._sessionplayer.assigninput( babase.InputType.RIGHT_PRESS, - babase.Call(self.handlemessage, ChangeMessage('team', 1)), + babase.CallStrict(self.handlemessage, ChangeMessage('team', 1)), ) self._sessionplayer.assigninput( babase.InputType.BOMB_PRESS, - babase.Call(self.handlemessage, ChangeMessage('character', 1)), + babase.CallStrict( + self.handlemessage, ChangeMessage('character', 1) + ), ) self._sessionplayer.assigninput( babase.InputType.UP_PRESS, - babase.Call( + babase.CallStrict( self.handlemessage, ChangeMessage('profileindex', -1) ), ) self._sessionplayer.assigninput( babase.InputType.DOWN_PRESS, - babase.Call( + babase.CallStrict( self.handlemessage, ChangeMessage('profileindex', 1) ), ) @@ -637,7 +642,9 @@ class Chooser: babase.InputType.PICK_UP_PRESS, babase.InputType.PUNCH_PRESS, ), - babase.Call(self.handlemessage, ChangeMessage('ready', 1)), + babase.CallStrict( + self.handlemessage, ChangeMessage('ready', 1) + ), ) self._ready = False self._update_text() @@ -662,7 +669,9 @@ class Chooser: babase.InputType.PICK_UP_PRESS, babase.InputType.PUNCH_PRESS, ), - babase.Call(self.handlemessage, ChangeMessage('ready', 0)), + babase.CallStrict( + self.handlemessage, ChangeMessage('ready', 0) + ), ) # Store the last profile picked by this input for reuse. diff --git a/dist/ba_data/python/bascenev1/_map.py b/dist/ba_data/python/bascenev1/_map.py index 5718289..b6ae04f 100644 --- a/dist/ba_data/python/bascenev1/_map.py +++ b/dist/ba_data/python/bascenev1/_map.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Map related functionality.""" + from __future__ import annotations import random diff --git a/dist/ba_data/python/bascenev1/_messages.py b/dist/ba_data/python/bascenev1/_messages.py index 2c8166c..9518241 100644 --- a/dist/ba_data/python/bascenev1/_messages.py +++ b/dist/ba_data/python/bascenev1/_messages.py @@ -99,7 +99,7 @@ class PlayerDiedMessage: Pass the Player type being used by the current game. """ - assert isinstance(self._killerplayer, (playertype, type(None))) + assert isinstance(self._killerplayer, playertype | None) return self._killerplayer def getplayer[PlayerT: bascenev1.Player]( diff --git a/dist/ba_data/python/bascenev1/_multiteamsession.py b/dist/ba_data/python/bascenev1/_multiteamsession.py index 85b6799..3faa06c 100644 --- a/dist/ba_data/python/bascenev1/_multiteamsession.py +++ b/dist/ba_data/python/bascenev1/_multiteamsession.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to teams sessions.""" + from __future__ import annotations import copy diff --git a/dist/ba_data/python/bascenev1/_net.py b/dist/ba_data/python/bascenev1/_net.py index 279c329..2a649c8 100644 --- a/dist/ba_data/python/bascenev1/_net.py +++ b/dist/ba_data/python/bascenev1/_net.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to net play.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/bascenev1/_player.py b/dist/ba_data/python/bascenev1/_player.py index 53a056b..4c937f7 100644 --- a/dist/ba_data/python/bascenev1/_player.py +++ b/dist/ba_data/python/bascenev1/_player.py @@ -35,7 +35,8 @@ class StandLocation: angle: float | None = None -class Player[TeamT: bascenev1.Team]: +# class Player[TeamT: bascenev1.Team]: +class Player[TeamT]: """A player in a specific bascenev1.Activity. These correspond to bascenev1.SessionPlayer objects, but are associated @@ -315,5 +316,5 @@ def playercast_o[PlayerT: bascenev1.Player]( totype: type[PlayerT], player: bascenev1.Player | None ) -> PlayerT | None: """A variant of bascenev1.playercast() for optional Player values.""" - assert isinstance(player, (totype, type(None))) + assert isinstance(player, totype | None) return player diff --git a/dist/ba_data/python/bascenev1/_profile.py b/dist/ba_data/python/bascenev1/_profile.py index a097456..6883cf1 100644 --- a/dist/ba_data/python/bascenev1/_profile.py +++ b/dist/ba_data/python/bascenev1/_profile.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to player profiles.""" + from __future__ import annotations import random diff --git a/dist/ba_data/python/bascenev1/_session.py b/dist/ba_data/python/bascenev1/_session.py index ae1594e..b021839 100644 --- a/dist/ba_data/python/bascenev1/_session.py +++ b/dist/ba_data/python/bascenev1/_session.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Defines base session class.""" + from __future__ import annotations import math @@ -24,7 +25,7 @@ if TYPE_CHECKING: _g_player_rejoin_cooldown: float = 0.0 # overrides the session's decision of max_players -_max_players_override: int | None = None +_g_max_players_override: int | None = None def set_player_rejoin_cooldown(cooldown: float) -> None: @@ -35,8 +36,8 @@ def set_player_rejoin_cooldown(cooldown: float) -> None: def set_max_players_override(max_players: int | None) -> None: """Set the override for how many players can join a session""" - global _max_players_override # pylint: disable=global-statement - _max_players_override = max_players + global _g_max_players_override # pylint: disable=global-statement + _g_max_players_override = max_players class Session: @@ -175,8 +176,8 @@ class Session: self.min_players = min_players self.max_players = ( max_players - if _max_players_override is None - else _max_players_override + if _g_max_players_override is None + else _g_max_players_override ) self.submit_score = submit_score @@ -338,7 +339,9 @@ class Session: with babase.ContextRef.empty(): self._waitlist_timers[identifier] = babase.AppTimer( _g_player_rejoin_cooldown, - babase.Call(self._remove_player_from_waitlist, identifier), + babase.CallStrict( + self._remove_player_from_waitlist, identifier + ), ) if not sessionplayer.in_game: @@ -372,7 +375,7 @@ class Session: # Grab their activity-specific player instance. player = sessionplayer.activityplayer - assert isinstance(player, (Player, type(None))) + assert isinstance(player, Player | None) # Remove them from any current Activity. if player is not None and activity is not None: @@ -498,7 +501,9 @@ class Session: # Set a timer to set in motion this activity's demise. self._activity_end_timer = _bascenev1.BaseTimer( delay, - babase.Call(self._complete_end_activity, activity, results), + babase.CallStrict( + self._complete_end_activity, activity, results + ), ) def handlemessage(self, msg: Any) -> Any: diff --git a/dist/ba_data/python/bascenev1/_stats.py b/dist/ba_data/python/bascenev1/_stats.py index d5a2877..698a00c 100644 --- a/dist/ba_data/python/bascenev1/_stats.py +++ b/dist/ba_data/python/bascenev1/_stats.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to scores and statistics.""" + from __future__ import annotations import random @@ -13,7 +14,6 @@ import babase import _bascenev1 - if TYPE_CHECKING: from typing import Any, Sequence @@ -237,7 +237,7 @@ class PlayerRecord: if name is not None: _bascenev1.timer( 0.3 + delay, - babase.Call( + babase.CallStrict( _apply, name, score, showpoints, color, scale, sound ), ) diff --git a/dist/ba_data/python/bascenev1/_team.py b/dist/ba_data/python/bascenev1/_team.py index d9b99ad..fc6001e 100644 --- a/dist/ba_data/python/bascenev1/_team.py +++ b/dist/ba_data/python/bascenev1/_team.py @@ -65,7 +65,8 @@ class SessionTeam: self.customdata = {} -class Team[PlayerT: bascenev1.Player]: +# class Team[PlayerT: bascenev1.Player]: +class Team[PlayerT]: """A team in a specific :class:`~bascenev1.Activity`. These correspond to :class:`~bascenev1.SessionTeam` objects, but are diff --git a/dist/ba_data/python/bascenev1lib/activity/coopjoin.py b/dist/ba_data/python/bascenev1lib/activity/coopjoin.py index f8f7506..0b8a28d 100644 --- a/dist/ba_data/python/bascenev1lib/activity/coopjoin.py +++ b/dist/ba_data/python/bascenev1lib/activity/coopjoin.py @@ -63,10 +63,10 @@ class CoopJoinActivity(bs.JoinActivity): vpos = -140.0 # Now list our remaining achievements for this level. - assert self.session.campaign is not None assert isinstance(self.session, bs.CoopSession) + assert self.session.campaign is not None levelname = ( - self.session.campaign.name + ':' + self.session.campaign_level_name + f'{self.session.campaign.name}:{self.session.campaign_level_name}' ) ts_h_offs = 60 diff --git a/dist/ba_data/python/bascenev1lib/activity/coopscore.py b/dist/ba_data/python/bascenev1lib/activity/coopscore.py index bb832dd..85a5f93 100644 --- a/dist/ba_data/python/bascenev1lib/activity/coopscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/coopscore.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides a score screen for coop games.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -10,7 +11,7 @@ import logging from typing import TYPE_CHECKING, override from efro.util import strict_partial -import bacommon.bs +import bacommon.classic from bacommon.login import LoginType import bascenev1 as bs import bauiv1 as bui @@ -133,10 +134,10 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): assert all(isinstance(i, bs.PlayerInfo) for i in self._playerinfos) self._score: int | None = settings['score'] - assert isinstance(self._score, (int, type(None))) + assert isinstance(self._score, int | None) self._fail_message: bs.Lstr | None = settings['fail_message'] - assert isinstance(self._fail_message, (bs.Lstr, type(None))) + assert isinstance(self._fail_message, bs.Lstr | None) self._begin_time: float | None = None @@ -205,7 +206,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): def _ui_menu(self) -> None: bui.containerwidget(edit=self._root_ui, transition='out_left') with self.context: - bs.timer(0.1, bs.Call(bs.WeakCall(self.session.end))) + bs.timer(0.1, bs.CallStrict(bs.WeakCallStrict(self.session.end))) def _ui_restart(self) -> None: from bauiv1lib.tournamententry import TournamentEntryWindow @@ -309,7 +310,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): bui.getsound('error').play() bs.timer( 2.0, - bs.WeakCall( + bs.WeakCallStrict( self._next_level_error.handlemessage, bs.DieMessage() ), ) @@ -332,7 +333,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): # main menu up, so instead we add a callback for when the menu # closes; if we're still alive, we'll come up then. # If there's no main menu this gets called immediately. - classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui)) + classic.add_main_menu_close_callback(bui.WeakCallStrict(self.show_ui)) def show_ui(self) -> None: """Show the UI for restarting, playing the next Level, etc.""" @@ -345,7 +346,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): delay = 0.7 if (self._score is not None) else 0.0 # If there's no players left in the game, lets not show the UI - # (that would allow restarting the game with zero players, etc). + # (it would allow restarting the game with zero players, etc). if not self.players: return @@ -373,7 +374,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): position=(h_offs - 520, v_offs + 450 - 235 + 40), size=(300, 60), label=bui.Lstr(resource='achievementsText'), - on_activate_call=bui.WeakCall(self._ui_show_achievements), + on_activate_call=bui.WeakCallStrict(self._ui_show_achievements), transition_delay=delay + 1.5, icon=self._game_service_achievements_texture, icon_color=self._game_service_icon_color, @@ -397,7 +398,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): ) ), autoselect=True, - on_activate_call=bui.WeakCall(self._ui_worlds_best), + on_activate_call=bui.WeakCallStrict(self._ui_worlds_best), transition_delay=delay + 1.9, selectable=can_select_extra_buttons, ) @@ -427,7 +428,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): size=(100, 50), label='', button_type='square', - on_activate_call=bui.WeakCall(self._ui_menu), + on_activate_call=bui.WeakCallStrict(self._ui_menu), ) bui.imagewidget( parent=rootc, @@ -444,7 +445,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): position=(h_offs - 130 - 60, v_offs), size=(110, 85), label='', - on_activate_call=bui.WeakCall(self._ui_menu), + on_activate_call=bui.WeakCallStrict(self._ui_menu), ) bui.imagewidget( parent=rootc, @@ -463,7 +464,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): size=(100, 50), label='', button_type='square', - on_activate_call=bui.WeakCall(self._ui_restart), + on_activate_call=bui.WeakCallStrict(self._ui_restart), ) bui.imagewidget( parent=rootc, @@ -480,7 +481,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): position=(h_offs - 60, v_offs), size=(110, 85), label='', - on_activate_call=bui.WeakCall(self._ui_restart), + on_activate_call=bui.WeakCallStrict(self._ui_restart), ) bui.imagewidget( parent=rootc, @@ -497,12 +498,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): # level yet and invisible if there is none. if show_next_button: if self._is_complete: - call = bui.WeakCall(self._ui_next) + call = bui.WeakCallStrict(self._ui_next) button_sound = True image_opacity = 0.8 color = None else: - call = bui.WeakCall(self._ui_error) + call = bui.WeakCallStrict(self._ui_error) button_sound = False image_opacity = 0.2 color = (0.3, 0.3, 0.3) @@ -615,7 +616,9 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): 0, self._birth_time + self._min_view_time - bs.time() ) - bs.timer(time_till_assign, bs.WeakCall(self._safe_assign, player)) + bs.timer( + time_till_assign, bs.WeakCallStrict(self._safe_assign, player) + ) @override def on_begin(self) -> None: @@ -661,7 +664,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): cfg.commit() self._campaign.set_selected_level(self._next_level_name) - bs.timer(1.0, bs.WeakCall(self.request_ui)) + bs.timer(1.0, bs.WeakCallStrict(self.request_ui)) variant = bs.app.env.variant vart = type(variant) @@ -793,9 +796,9 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): self._show_info = {} if self._score is not None: - bs.timer(0.8, bs.WeakCall(self._show_score_val, offs_x)) + bs.timer(0.8, bs.WeakCallStrict(self._show_score_val, offs_x)) else: - bs.pushcall(bs.WeakCall(self._show_fail)) + bs.pushcall(bs.WeakCallStrict(self._show_fail)) self._name_str = name_str = ', '.join( [p.name for p in self._playerinfos] @@ -814,7 +817,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): ) if self._score is not None and self._submit_score: - bs.timer(0.4, bs.WeakCall(self._play_drumroll)) + bs.timer(0.4, bs.WeakCallStrict(self._play_drumroll)) # Add us to high scores, filter, and store. our_high_scores_all = self._campaign.getlevel( @@ -866,7 +869,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): # We expect this only in kiosk mode; complain otherwise. if not arcade_or_demo: logging.error('got not-signed-in at score-submit; unexpected') - bs.pushcall(bs.WeakCall(self._got_score_results, None)) + bs.pushcall(bs.WeakCallStrict(self._got_score_results, None)) else: assert self._game_name_str is not None assert self._game_config_str is not None @@ -875,7 +878,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): self._game_config_str, name_str, self._score, - bs.WeakCall(self._got_score_results), + bs.WeakCallPartial(self._got_score_results), order=self._score_order, tournament_id=self.session.tournament_id, score_type=self._score_type, @@ -1059,7 +1062,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): vval -= 55 tdelay += 0.250 - bs.timer(5.0, bs.WeakCall(self._show_tips)) + bs.timer(5.0, bs.WeakCallStrict(self._show_tips)) def _play_drumroll(self) -> None: bs.NodeActor( @@ -1209,14 +1212,14 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): ).autoretain() def _on_v2_score_results( - self, response: bacommon.bs.ScoreSubmitResponse | Exception + self, response: bacommon.classic.ScoreSubmitResponse | Exception ) -> None: if isinstance(response, Exception): logging.debug('Got error score-submit response: %s', response) return - assert isinstance(response, bacommon.bs.ScoreSubmitResponse) + assert isinstance(response, bacommon.classic.ScoreSubmitResponse) # Aim to have these effects run shortly after the final rating # hit happens. @@ -1275,8 +1278,10 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): ): with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.ScoreSubmitMessage(score_token), - on_response=bui.WeakCall(self._on_v2_score_results), + bacommon.classic.ScoreSubmitMessage(score_token), + on_response=bui.WeakCallPartial( + self._on_v2_score_results + ), ) self._score_link = results['link'] @@ -1297,7 +1302,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): self._tournament_time_remaining = secs_remaining self._tournament_time_remaining_text_timer = bs.BaseTimer( 1.0, - bs.WeakCall( + bs.WeakCallStrict( self._update_tournament_time_remaining_text ), repeat=True, @@ -1315,7 +1320,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): if self._score is not None: bs.basetimer( (1.5 + base_delay), - bs.WeakCall(self._show_world_rank, offs_x), + bs.WeakCallStrict(self._show_world_rank, offs_x), ) ts_h_offs = 280 ts_height = 300 @@ -1574,7 +1579,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): ] # pylint: disable=useless-suppression # pylint: disable=unbalanced-tuple-unpacking - (pr1, pv1, pr2, pv2, pr3, pv3) = ( + pr1, pv1, pr2, pv2, pr3, pv3 = ( bs.app.classic.get_tournament_prize_strings( tourney_info, include_tickets=False ) diff --git a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py index 6929a10..ef5d70a 100644 --- a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py @@ -61,7 +61,7 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): for team in self.session.sessionteams: bs.timer( i * 0.15 + 0.15, - bs.WeakCall( + bs.WeakCallStrict( self._show_team_name, vval - i * height, team, @@ -76,7 +76,7 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): delay = 1.2 bs.timer( i * 0.150 + 0.2, - bs.WeakCall( + bs.WeakCallStrict( self._show_team_old_score, vval - i * height, team, @@ -87,7 +87,7 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): bs.timer( i * 0.150 + delay, - bs.WeakCall( + bs.WeakCallStrict( self._show_team_score, vval - i * height, team, diff --git a/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py b/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py index 3f88e3b..e05a643 100644 --- a/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py +++ b/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py @@ -132,7 +132,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, title.position_combine, 'input0', @@ -160,7 +160,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ).autoretain() bs.timer( tdelay + delay2, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, img.position_combine, 'input1', @@ -172,7 +172,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, img.position_combine, 'input0', @@ -198,7 +198,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ).autoretain() bs.timer( tdelay + delay2, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, txt.position_combine, 'input1', @@ -210,7 +210,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, txt.position_combine, 'input0', @@ -235,7 +235,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ).autoretain() bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, txt_num.position_combine, 'input0', @@ -256,7 +256,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay2, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, s_txt.position_combine, 'input1', @@ -268,7 +268,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, s_txt.position_combine, 'input0', @@ -297,7 +297,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay2, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, s_txt_2.position_combine, 'input1', @@ -309,7 +309,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) bs.timer( tdelay + delay3, - bs.WeakCall( + bs.WeakCallStrict( self._safe_animate, s_txt_2.position_combine, 'input0', @@ -328,12 +328,14 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): bs.timer( tdelay + delay1, - bs.Call(_safesetattr, s_txt.node, 'color', (1, 1, 1, 1)), + bs.CallStrict( + _safesetattr, s_txt.node, 'color', (1, 1, 1, 1) + ), ) for j in range(score_change): bs.timer( (tdelay + delay1 + 0.15 * j), - bs.Call( + bs.CallStrict( _safesetattr, s_txt.node, 'text', diff --git a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py index 0bf4bd3..b8f6089 100644 --- a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to teams mode score screen.""" + from __future__ import annotations from typing import override diff --git a/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py b/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py index 058ffd4..8052893 100644 --- a/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py +++ b/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py @@ -4,14 +4,14 @@ from __future__ import annotations -from typing import override, TYPE_CHECKING +from typing import override, TYPE_CHECKING, Any, cast import bascenev1 as bs from bascenev1lib.activity.multiteamscore import MultiTeamScoreScreenActivity if TYPE_CHECKING: - from typing import Any + pass class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): @@ -54,9 +54,10 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): winning_sessionteam = self.settings_raw['winner'] # Pause a moment before playing victory music. - bs.timer(0.6, bs.WeakCall(self._play_victory_music)) + bs.timer(0.6, bs.WeakCallStrict(self._play_victory_music)) bs.timer( - 4.4, bs.WeakCall(self._show_winner, self.settings_raw['winner']) + 4.4, + bs.WeakCallStrict(self._show_winner, self.settings_raw['winner']), ) bs.timer(4.6, self._score_display_sound.play) @@ -76,9 +77,9 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): ) player_entries.sort(reverse=True, key=lambda x: x[0]) if len(player_entries) > 0: - # Store some info for the top ffa player so we can - # show winner info even if they leave. - self._ffa_top_player_info = list(player_entries[0]) + # Store some info for the top ffa player so we can show + # winner info even if they leave. + self._ffa_top_player_info = cast(Any, list(player_entries[0])) self._ffa_top_player_info[1] = self._ffa_top_player_info[ 2 ].getname() @@ -178,6 +179,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): transition_delay=t_incr * 4, ).autoretain() + assert isinstance(session, bs.MultiTeamSession) win_score = (session.get_series_length() - 1) // 2 + 1 lose_score = 0 for team in self.teams: @@ -421,7 +423,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): transition_delay=tdelay, ).autoretain() - bs.timer(15.0, bs.WeakCall(self._show_tips)) + bs.timer(15.0, bs.WeakCallStrict(self._show_tips)) def _show_tips(self) -> None: from bascenev1lib.actor.tipstext import TipsText diff --git a/dist/ba_data/python/bascenev1lib/actor/bomb.py b/dist/ba_data/python/bascenev1lib/actor/bomb.py index 04dc297..a4b92a3 100644 --- a/dist/ba_data/python/bascenev1lib/actor/bomb.py +++ b/dist/ba_data/python/bascenev1lib/actor/bomb.py @@ -864,10 +864,11 @@ class Bomb(bs.Actor): }, ) self.arm_timer = bs.Timer( - 0.2, bs.WeakCall(self.handlemessage, ArmMessage()) + 0.2, bs.WeakCallStrict(self.handlemessage, ArmMessage()) ) self.warn_timer = bs.Timer( - fuse_time - 1.7, bs.WeakCall(self.handlemessage, WarnMessage()) + fuse_time - 1.7, + bs.WeakCallStrict(self.handlemessage, WarnMessage()), ) else: @@ -918,7 +919,8 @@ class Bomb(bs.Actor): if self.bomb_type not in ('land_mine', 'tnt'): assert fuse_time is not None bs.timer( - fuse_time, bs.WeakCall(self.handlemessage, ExplodeMessage()) + fuse_time, + bs.WeakCallStrict(self.handlemessage, ExplodeMessage()), ) bs.animate( @@ -976,7 +978,7 @@ class Bomb(bs.Actor): def _handle_dropped(self) -> None: if self.bomb_type == 'land_mine': self.arm_timer = bs.Timer( - 1.25, bs.WeakCall(self.handlemessage, ArmMessage()) + 1.25, bs.WeakCallStrict(self.handlemessage, ArmMessage()) ) # Once we've thrown a sticky bomb we can stick to it. @@ -1028,7 +1030,7 @@ class Bomb(bs.Actor): # We blew up so we need to go away. # NOTE TO SELF: do we actually need this delay? - bs.timer(0.001, bs.WeakCall(self.handlemessage, bs.DieMessage())) + bs.timer(0.001, bs.WeakCallStrict(self.handlemessage, bs.DieMessage())) def _handle_warn(self) -> None: if self.texture_sequence and self.node: @@ -1064,7 +1066,7 @@ class Bomb(bs.Actor): # We now make it explodable. bs.timer( 0.25, - bs.WeakCall( + bs.WeakCallStrict( self._add_material, factory.land_mine_blast_material ), ) @@ -1081,7 +1083,7 @@ class Bomb(bs.Actor): ) bs.timer( 0.25, - bs.WeakCall( + bs.WeakCallStrict( self._add_material, factory.land_mine_blast_material ), ) @@ -1121,7 +1123,7 @@ class Bomb(bs.Actor): bs.timer( 0.1 + random.random() * 0.1, - bs.WeakCall(self.handlemessage, ExplodeMessage()), + bs.WeakCallStrict(self.handlemessage, ExplodeMessage()), ) assert self.node self.node.handlemessage( @@ -1190,7 +1192,7 @@ class TNTSpawner: # Go with slightly more than 1 second to avoid timer stacking. self._update_timer = bs.Timer( - 1.1, bs.WeakCall(self._update), repeat=True + 1.1, bs.WeakCallStrict(self._update), repeat=True ) def _update(self) -> None: diff --git a/dist/ba_data/python/bascenev1lib/actor/controlsguide.py b/dist/ba_data/python/bascenev1lib/actor/controlsguide.py index 40b3f18..966d41e 100644 --- a/dist/ba_data/python/bascenev1lib/actor/controlsguide.py +++ b/dist/ba_data/python/bascenev1lib/actor/controlsguide.py @@ -263,7 +263,7 @@ class ControlsGuide(bs.Actor): node.opacity = 0.0 # Don't do anything until our delay has passed. - bs.timer(delay, bs.WeakCall(self._start_updating)) + bs.timer(delay, bs.WeakCallStrict(self._start_updating)) @staticmethod def _meaningful_button_name( @@ -290,10 +290,12 @@ class ControlsGuide(bs.Actor): if self._lifespan is not None: self._cancel_timer = bs.Timer( self._lifespan, - bs.WeakCall(self.handlemessage, bs.DieMessage(immediate=True)), + bs.WeakCallStrict( + self.handlemessage, bs.DieMessage(immediate=True) + ), ) self._fade_in_timer = bs.Timer( - 1.0, bs.WeakCall(self._check_fade_in), repeat=True + 1.0, bs.WeakCallStrict(self._check_fade_in), repeat=True ) self._check_fade_in() # Do one check immediately. @@ -349,11 +351,12 @@ class ControlsGuide(bs.Actor): # If we were given a lifespan, transition out after it. if self._lifespan is not None: bs.timer( - self._lifespan, bs.WeakCall(self.handlemessage, bs.DieMessage()) + self._lifespan, + bs.WeakCallStrict(self.handlemessage, bs.DieMessage()), ) self._update() self._update_timer = bs.Timer( - 1.0, bs.WeakCall(self._update), repeat=True + 1.0, bs.WeakCallStrict(self._update), repeat=True ) def _update(self) -> None: @@ -565,6 +568,6 @@ class ControlsGuide(bs.Actor): # die later. for node in self._nodes: bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0}) - bs.timer(3.1, bs.WeakCall(self._die)) + bs.timer(3.1, bs.WeakCallStrict(self._die)) return None return super().handlemessage(msg) diff --git a/dist/ba_data/python/bascenev1lib/actor/flag.py b/dist/ba_data/python/bascenev1lib/actor/flag.py index 591c73b..3d42d02 100644 --- a/dist/ba_data/python/bascenev1lib/actor/flag.py +++ b/dist/ba_data/python/bascenev1lib/actor/flag.py @@ -211,7 +211,7 @@ class Flag(bs.Actor): if self._dropped_timeout is not None: self._count = self._dropped_timeout self._tick_timer = bs.Timer( - 1.0, call=bs.WeakCall(self._tick), repeat=True + 1.0, call=bs.WeakCallStrict(self._tick), repeat=True ) self._counter = bs.newnode( 'text', @@ -314,7 +314,7 @@ class Flag(bs.Actor): self._score_text.color = bs.safecolor(self.node.color) bs.animate(self._score_text, 'scale', {0: start_scale, 0.2: 0.02}) self._score_text_hide_timer = bs.Timer( - 1.0, bs.WeakCall(self._hide_score_text) + 1.0, bs.WeakCallStrict(self._hide_score_text) ) @override diff --git a/dist/ba_data/python/bascenev1lib/actor/image.py b/dist/ba_data/python/bascenev1lib/actor/image.py index 4e38f6b..bf84859 100644 --- a/dist/ba_data/python/bascenev1lib/actor/image.py +++ b/dist/ba_data/python/bascenev1lib/actor/image.py @@ -169,7 +169,7 @@ class Image(bs.Actor): if transition_out_delay is not None: bs.timer( transition_delay + transition_out_delay + 1.0, - bs.WeakCall(self.handlemessage, bs.DieMessage()), + bs.WeakCallStrict(self.handlemessage, bs.DieMessage()), ) @override diff --git a/dist/ba_data/python/bascenev1lib/actor/onscreentimer.py b/dist/ba_data/python/bascenev1lib/actor/onscreentimer.py index 196674e..8326aaf 100644 --- a/dist/ba_data/python/bascenev1lib/actor/onscreentimer.py +++ b/dist/ba_data/python/bascenev1lib/actor/onscreentimer.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Defines Actor(s).""" + from __future__ import annotations from typing import TYPE_CHECKING, override diff --git a/dist/ba_data/python/bascenev1lib/actor/popuptext.py b/dist/ba_data/python/bascenev1lib/actor/popuptext.py index 9c0d09c..e332954 100644 --- a/dist/ba_data/python/bascenev1lib/actor/popuptext.py +++ b/dist/ba_data/python/bascenev1lib/actor/popuptext.py @@ -116,7 +116,7 @@ class PopupText(bs.Actor): # kill ourself self._die_timer = bs.Timer( - lifespan, bs.WeakCall(self.handlemessage, bs.DieMessage()) + lifespan, bs.WeakCallStrict(self.handlemessage, bs.DieMessage()) ) @override diff --git a/dist/ba_data/python/bascenev1lib/actor/powerupbox.py b/dist/ba_data/python/bascenev1lib/actor/powerupbox.py index dd19b54..cf3f866 100644 --- a/dist/ba_data/python/bascenev1lib/actor/powerupbox.py +++ b/dist/ba_data/python/bascenev1lib/actor/powerupbox.py @@ -265,11 +265,11 @@ class PowerupBox(bs.Actor): if expire: bs.timer( DEFAULT_POWERUP_INTERVAL - 2.5, - bs.WeakCall(self._start_flashing), + bs.WeakCallStrict(self._start_flashing), ) bs.timer( DEFAULT_POWERUP_INTERVAL - 1.0, - bs.WeakCall(self.handlemessage, bs.DieMessage()), + bs.WeakCallStrict(self.handlemessage, bs.DieMessage()), ) def _start_flashing(self) -> None: diff --git a/dist/ba_data/python/bascenev1lib/actor/respawnicon.py b/dist/ba_data/python/bascenev1lib/actor/respawnicon.py index 3e31350..b5b5b58 100644 --- a/dist/ba_data/python/bascenev1lib/actor/respawnicon.py +++ b/dist/ba_data/python/bascenev1lib/actor/respawnicon.py @@ -66,6 +66,7 @@ class RespawnIcon: ) ) + assert self._image assert self._image.node bs.animate(self._image.node, 'opacity', {0.0: 0, 0.2: 0.7}) @@ -89,6 +90,7 @@ class RespawnIcon: ) ) + assert self._name assert self._name.node bs.animate(self._name.node, 'scale', {0: 0, 0.1: 0.5}) @@ -133,6 +135,7 @@ class RespawnIcon: ) ) + assert self._text assert self._text.node bs.animate(self._text.node, 'scale', {0: 0, 0.1: 0.9}) if self._dec_text: @@ -142,7 +145,7 @@ class RespawnIcon: self._dec_timer: bs.Timer | None = None self._update() self._timer: bs.Timer | None = bs.Timer( - 1.0, bs.WeakCall(self._update), repeat=True + 1.0, bs.WeakCallStrict(self._update), repeat=True ) @property @@ -211,7 +214,7 @@ class RespawnIcon: # Start the timer to tick down. self._dec_timer = bs.Timer( 0.25, - bs.WeakCall(self._dec_step, ['..', '.', '']), + bs.WeakCallStrict(self._dec_step, ['..', '.', '']), repeat=True, ) else: diff --git a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py index 18ba89d..3c0940a 100644 --- a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py +++ b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py @@ -189,7 +189,7 @@ class _Entry: def flash(self, countdown: bool, extra_flash: bool) -> None: """Flash momentarily.""" self._flash_timer = bs.Timer( - 0.1, bs.WeakCall(self._do_flash), repeat=True + 0.1, bs.WeakCallStrict(self._do_flash), repeat=True ) if countdown: self._flash_counter = 10 @@ -358,7 +358,7 @@ class _EntryProxy: return try: - bs.pushcall(bs.Call(scoreboard.remove_team, self._team_id)) + bs.pushcall(bs.CallStrict(scoreboard.remove_team, self._team_id)) except bs.ContextError: # This happens if we fire after the activity expires. # In that case we don't need to do anything. diff --git a/dist/ba_data/python/bascenev1lib/actor/spaz.py b/dist/ba_data/python/bascenev1lib/actor/spaz.py index f5bfab0..dbeb5f9 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spaz.py +++ b/dist/ba_data/python/bascenev1lib/actor/spaz.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Defines the spaz actor.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -172,7 +173,9 @@ class Spaz(bs.Actor): if node: setattr(node, attr, val) - bs.timer(1.0, bs.Call(_safesetattr, self.node, 'invincible', False)) + bs.timer( + 1.0, bs.CallStrict(_safesetattr, self.node, 'invincible', False) + ) self.hitpoints = self.default_hitpoints self.hitpoints_max = self.default_hitpoints self.shield_hitpoints: int | None = None @@ -376,7 +379,7 @@ class Spaz(bs.Actor): bs.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02}) self._score_text_hide_timer = bs.Timer( - 1.0, bs.WeakCall(self._hide_score_text) + 1.0, bs.WeakCallStrict(self._hide_score_text) ) def on_jump_press(self) -> None: @@ -462,7 +465,7 @@ class Spaz(bs.Actor): if not self.node.hold_node: bs.timer( 0.1, - bs.WeakCall( + bs.WeakCallStrict( self._safe_play_sound, SpazFactory.get().swish_sound, 0.8, @@ -629,7 +632,9 @@ class Spaz(bs.Actor): ) self._curse_timer = bs.Timer( self.curse_time, - bs.WeakCall(self.handlemessage, CurseExplodeMessage()), + bs.WeakCallStrict( + self.handlemessage, CurseExplodeMessage() + ), ) def equip_boxing_gloves(self) -> None: @@ -671,7 +676,7 @@ class Spaz(bs.Actor): if self.shield_decay_rate > 0: self.shield_decay_timer = bs.Timer( - 0.5, bs.WeakCall(self.shield_decay), repeat=True + 0.5, bs.WeakCallStrict(self.shield_decay), repeat=True ) # So user can see the decay. self.shield.always_show_health_bar = True @@ -718,12 +723,12 @@ class Spaz(bs.Actor): # Eww; seems we have to do this in a timer or it wont work right. # (since we're getting called from within update() perhaps?..) # NOTE: should test to see if that's still the case. - bs.timer(0.001, bs.WeakCall(self.shatter)) + bs.timer(0.001, bs.WeakCallStrict(self.shatter)) elif isinstance(msg, bs.ImpactDamageMessage): # Eww; seems we have to do this in a timer or it wont work right. # (since we're getting called from within update() perhaps?..) - bs.timer(0.001, bs.WeakCall(self._hit_self, msg.intensity)) + bs.timer(0.001, bs.WeakCallStrict(self._hit_self, msg.intensity)) elif isinstance(msg, bs.PowerupMessage): if self._dead or not self.node: @@ -744,11 +749,11 @@ class Spaz(bs.Actor): ) self._multi_bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCall(self._multi_bomb_wear_off_flash), + bs.WeakCallStrict(self._multi_bomb_wear_off_flash), ) self._multi_bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, - bs.WeakCall(self._multi_bomb_wear_off), + bs.WeakCallStrict(self._multi_bomb_wear_off), ) elif msg.poweruptype == 'land_mines': self.set_land_mine_count(min(self.land_mine_count + 3, 3)) @@ -766,11 +771,11 @@ class Spaz(bs.Actor): ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCall(self._bomb_wear_off_flash), + bs.WeakCallStrict(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, - bs.WeakCall(self._bomb_wear_off), + bs.WeakCallStrict(self._bomb_wear_off), ) elif msg.poweruptype == 'sticky_bombs': self.bomb_type = 'sticky' @@ -786,11 +791,11 @@ class Spaz(bs.Actor): ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCall(self._bomb_wear_off_flash), + bs.WeakCallStrict(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, - bs.WeakCall(self._bomb_wear_off), + bs.WeakCallStrict(self._bomb_wear_off), ) elif msg.poweruptype == 'punch': tex = PowerupBoxFactory.get().tex_punch @@ -807,11 +812,11 @@ class Spaz(bs.Actor): ) self._boxing_gloves_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCall(self._gloves_wear_off_flash), + bs.WeakCallStrict(self._gloves_wear_off_flash), ) self._boxing_gloves_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, - bs.WeakCall(self._gloves_wear_off), + bs.WeakCallStrict(self._gloves_wear_off), ) elif msg.poweruptype == 'shield': factory = SpazFactory.get() @@ -834,11 +839,11 @@ class Spaz(bs.Actor): ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, - bs.WeakCall(self._bomb_wear_off_flash), + bs.WeakCallStrict(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, - bs.WeakCall(self._bomb_wear_off), + bs.WeakCallStrict(self._bomb_wear_off), ) elif msg.poweruptype == 'health': if self._cursed: @@ -885,7 +890,8 @@ class Spaz(bs.Actor): self.frozen = True self.node.frozen = True bs.timer( - msg.time, bs.WeakCall(self.handlemessage, bs.ThawMessage()) + msg.time, + bs.WeakCallStrict(self.handlemessage, bs.ThawMessage()), ) # Instantly shatter if we're already dead. # (otherwise its hard to tell we're dead). @@ -1165,7 +1171,7 @@ class Spaz(bs.Actor): if self._cursed and damage > 0: bs.timer( 0.05, - bs.WeakCall( + bs.WeakCallStrict( self.curse_explode, msg.get_source_player(bs.Player) ), ) @@ -1384,7 +1390,7 @@ class Spaz(bs.Actor): if dropping_bomb: self.bomb_count -= 1 bomb.node.add_death_action( - bs.WeakCall(self.handlemessage, BombDiedMessage()) + bs.WeakCallStrict(self.handlemessage, BombDiedMessage()) ) self._pick_up(bomb.node) diff --git a/dist/ba_data/python/bascenev1lib/actor/spazappearance.py b/dist/ba_data/python/bascenev1lib/actor/spazappearance.py index e7521c9..1432bfc 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spazappearance.py +++ b/dist/ba_data/python/bascenev1lib/actor/spazappearance.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Appearance functionality for spazzes.""" + from __future__ import annotations import bascenev1 as bs @@ -818,9 +819,9 @@ def register_appearances() -> None: t.death_sounds = ['oldLadyDeath'] t.pickup_sounds = old_lady_sounds t.fall_sounds = ['oldLadyFall'] - t.style = 'spaz' - t.default_color = (0.3, 0.5, 0.8) - t.default_highlight = (1, 0, 0) + t.style = 'bones' + t.default_color = (0.2, 1.0, 1.0) + t.default_highlight = (0.5, 0.25, 1.0) # Gladiator ################################### t = Appearance('Gladiator') diff --git a/dist/ba_data/python/bascenev1lib/actor/spazbot.py b/dist/ba_data/python/bascenev1lib/actor/spazbot.py index 7cbbcc8..660fa66 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spazbot.py +++ b/dist/ba_data/python/bascenev1lib/actor/spazbot.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Bot versions of Spaz.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -111,12 +112,14 @@ class SpazBot(Spaz): can_accept_powerups=False, ) + from bascenev1lib.mainmenu import MainMenuActivity + # If you need to add custom behavior to a bot, set this to a callable # which takes one arg (the bot) and returns False if the bot's normal # update should be run and True if not. self.update_callback: Callable[[SpazBot], Any] | None = None activity = self.activity - assert isinstance(activity, bs.GameActivity) + assert isinstance(activity, (bs.GameActivity, MainMenuActivity)) self._map = weakref.ref(activity.map) self.last_player_attacked_by: bs.Player | None = None self.last_attacked_time = 0.0 @@ -327,10 +330,10 @@ class SpazBot(Spaz): self.node.jump_pressed = False # Throws: - bs.timer(0.1, bs.Call(_safe_pickup, self.node)) + bs.timer(0.1, bs.CallStrict(_safe_pickup, self.node)) else: # Throws: - bs.timer(0.1, bs.Call(_safe_pickup, self.node)) + bs.timer(0.1, bs.CallStrict(_safe_pickup, self.node)) if self.static: if time_till_throw < 0.3: @@ -906,6 +909,67 @@ class ExplodeyBotShielded(ExplodeyBot): points_mult = 5 +class DemoBot(SpazBot): + """A bs.SpazBot who lacks many specific traits and is used for the "Bots + Free-for-All" easter egg. + + category: Bot Classes + """ + + run = True + + @classmethod + def randomize_traits(cls, appearance: str) -> None: + """Randomize the behavioral traits of the bot. Should be called + everytime before creating a new instance. + """ + + cls.color = (random.random(), random.random(), random.random()) + cls.highlight = (random.random(), random.random(), random.random()) + cls.character = appearance + cls.punchiness = random.uniform(0.5, 1.0) + cls.throwiness = random.uniform(0.5, 1.0) + cls.bouncy = appearance == 'Easter Bunny' + cls.throw_rate = random.uniform(0.5, 2.0) + cls.default_bomb_type = random.choice( + ('normal', 'sticky', 'ice', 'impact') + ) + cls.default_boxing_gloves = random.choice((True, False, False, False)) + + @override + def __init__(self) -> None: + super().__init__() + self._init_time = bs.time() + + @override + def handlemessage(self, msg: Any) -> Any: + if ( + isinstance(msg, bs.HitMessage) + and self.node + and bs.time() - self._init_time <= 1.0 + ): + assert msg.force_direction is not None + self.node.handlemessage( + 'impulse', + msg.pos[0], + msg.pos[1], + msg.pos[2], + msg.velocity[0], + msg.velocity[1], + msg.velocity[2], + msg.magnitude * self.impact_scale, + msg.velocity_magnitude * self.impact_scale, + msg.radius, + 0, + msg.force_direction[0], + msg.force_direction[1], + msg.force_direction[2], + ) + self.node.handlemessage('hurt_sound') + return None + return super().handlemessage(msg) + + class SpazBotSet: """A container/controller for one or more bs.SpazBots. @@ -945,7 +1009,7 @@ class SpazBotSet: pt=pos, spawn_time=spawn_time, send_spawn_message=False, - spawn_callback=bs.Call( + spawn_callback=bs.CallStrict( self._spawn_bot, bot_type, pos, on_spawn_call ), ) @@ -1040,7 +1104,7 @@ class SpazBotSet: def start_moving(self) -> None: """Start processing bot AI updates so they start doing their thing.""" self._bot_update_timer = bs.Timer( - 0.05, bs.WeakCall(self._update), repeat=True + 0.05, bs.WeakCallStrict(self._update), repeat=True ) def stop_moving(self) -> None: @@ -1083,7 +1147,7 @@ class SpazBotSet: bot.node.move_up_down = 0 bs.timer( 0.5 * random.random(), - bs.Call(bot.handlemessage, bs.CelebrateMessage()), + bs.CallStrict(bot.handlemessage, bs.CelebrateMessage()), ) jump_duration = random.randrange(400, 500) j = random.randrange(0, 200) @@ -1093,18 +1157,58 @@ class SpazBotSet: j += jump_duration bs.timer( random.uniform(0.0, 1.0), - bs.Call(bot.node.handlemessage, 'attack_sound'), + bs.CallStrict(bot.node.handlemessage, 'attack_sound'), ) bs.timer( random.uniform(1.0, 2.0), - bs.Call(bot.node.handlemessage, 'attack_sound'), + bs.CallStrict(bot.node.handlemessage, 'attack_sound'), ) bs.timer( random.uniform(2.0, 3.0), - bs.Call(bot.node.handlemessage, 'attack_sound'), + bs.CallStrict(bot.node.handlemessage, 'attack_sound'), ) def add_bot(self, bot: SpazBot) -> None: """Add a bs.SpazBot instance to the set.""" self._bot_lists[self._bot_add_list].append(bot) self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count + + +class DemoSpazBotSet(SpazBotSet): + """A bs.SpazBotSet that has its bs.SpazBots attack every other bs.Spaz + instead of only going after bs.Players. + + category: Bot Classes + """ + + @override + def _update(self) -> None: + # Update one of our bot lists each time through. + # First off, remove no-longer-existing bots from the list. + try: + bot_list = self._bot_lists[self._bot_update_list] = [ + b for b in self._bot_lists[self._bot_update_list] if b + ] + except Exception: + bot_list = [] + logging.exception( + 'Error updating bot list: %s', + self._bot_lists[self._bot_update_list], + ) + self._bot_update_list = ( + self._bot_update_list + 1 + ) % self._bot_list_count + + # Update our list of player points for the bots to use. + spaz_pts = [] + our_bots = self.get_living_bots() + for node in bs.getnodes(): + spaz = node.getdelegate(Spaz) + if spaz and spaz.is_alive() and spaz not in our_bots: + spaz_pts.append( + (bs.Vec3(node.position), bs.Vec3(node.velocity)) + ) + + for bot in bot_list: + bot.set_player_points(spaz_pts) + bot.update_ai() diff --git a/dist/ba_data/python/bascenev1lib/actor/text.py b/dist/ba_data/python/bascenev1lib/actor/text.py index 42611e8..6fbed08 100644 --- a/dist/ba_data/python/bascenev1lib/actor/text.py +++ b/dist/ba_data/python/bascenev1lib/actor/text.py @@ -219,7 +219,7 @@ class Text(bs.Actor): if transition_out_delay is not None: bs.timer( transition_delay + transition_out_delay + 1.0, - bs.WeakCall(self.handlemessage, bs.DieMessage()), + bs.WeakCallStrict(self.handlemessage, bs.DieMessage()), ) @override diff --git a/dist/ba_data/python/bascenev1lib/actor/tipstext.py b/dist/ba_data/python/bascenev1lib/actor/tipstext.py index 9c16168..371188f 100644 --- a/dist/ba_data/python/bascenev1lib/actor/tipstext.py +++ b/dist/ba_data/python/bascenev1lib/actor/tipstext.py @@ -53,7 +53,7 @@ class TipsText(bs.Actor): self._message_spacing = 3000 self._change_timer = bs.Timer( 0.001 * (self._message_duration + self._message_spacing), - bs.WeakCall(self.change_phrase), + bs.WeakCallStrict(self.change_phrase), repeat=True, ) self._combine = bs.newnode( diff --git a/dist/ba_data/python/bascenev1lib/actor/zoomtext.py b/dist/ba_data/python/bascenev1lib/actor/zoomtext.py index d65b728..6049db2 100644 --- a/dist/ba_data/python/bascenev1lib/actor/zoomtext.py +++ b/dist/ba_data/python/bascenev1lib/actor/zoomtext.py @@ -81,12 +81,14 @@ class ZoomText(bs.Actor): positionadjusted2 = (shiftposition[0], shiftposition[1] - 100) bs.timer( shiftdelay, - bs.WeakCall(self._shift, positionadjusted, positionadjusted2), + bs.WeakCallStrict( + self._shift, positionadjusted, positionadjusted2 + ), ) if jitter > 0.0: bs.timer( shiftdelay + 0.25, - bs.WeakCall( + bs.WeakCallStrict( self._jitter, positionadjusted2, jitter * scale ), ) @@ -155,7 +157,9 @@ class ZoomText(bs.Actor): # if they give us a lifespan, kill ourself down the line if lifespan is not None: - bs.timer(lifespan, bs.WeakCall(self.handlemessage, bs.DieMessage())) + bs.timer( + lifespan, bs.WeakCallStrict(self.handlemessage, bs.DieMessage()) + ) @override def handlemessage(self, msg: Any) -> Any: diff --git a/dist/ba_data/python/bascenev1lib/game/assault.py b/dist/ba_data/python/bascenev1lib/game/assault.py index 1f0938c..e9bf756 100644 --- a/dist/ba_data/python/bascenev1lib/game/assault.py +++ b/dist/ba_data/python/bascenev1lib/game/assault.py @@ -149,7 +149,7 @@ class AssaultGame(bs.TeamGameActivity[Player, Team]): ( 'call', 'at_connect', - bs.Call(self._handle_base_collide, team), + bs.CallStrict(self._handle_base_collide, team), ), ), ) @@ -270,7 +270,7 @@ class AssaultGame(bs.TeamGameActivity[Player, Team]): self._teleport(player, new_pos, random_num) bs.timer( 0.01, - bs.Call( + bs.CallStrict( self._teleport, player, new_pos, random_num ), ) diff --git a/dist/ba_data/python/bascenev1lib/game/capturetheflag.py b/dist/ba_data/python/bascenev1lib/game/capturetheflag.py index e62820d..cbe397b 100644 --- a/dist/ba_data/python/bascenev1lib/game/capturetheflag.py +++ b/dist/ba_data/python/bascenev1lib/game/capturetheflag.py @@ -534,7 +534,7 @@ class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]): if team.flag_return_touches == 1: team.touch_return_timer = bs.Timer( 0.1, - call=bs.Call(self._touch_return_update, team), + call=bs.CallStrict(self._touch_return_update, team), repeat=True, ) team.touch_return_timer_ticking = None @@ -646,7 +646,9 @@ class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]): elif isinstance(msg, FlagDiedMessage): assert isinstance(msg.flag, CTFFlag) - bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team)) + bs.timer( + 0.1, bs.CallStrict(self._spawn_flag_for_team, msg.flag.team) + ) elif isinstance(msg, FlagPickedUpMessage): # Store the last player to hold the flag for scoring purposes. diff --git a/dist/ba_data/python/bascenev1lib/game/chosenone.py b/dist/ba_data/python/bascenev1lib/game/chosenone.py index 983521d..73bd609 100644 --- a/dist/ba_data/python/bascenev1lib/game/chosenone.py +++ b/dist/ba_data/python/bascenev1lib/game/chosenone.py @@ -168,7 +168,11 @@ class ChosenOneGame(bs.TeamGameActivity[Player, Team]): actions=( ('modify_part_collision', 'collide', True), ('modify_part_collision', 'physical', False), - ('call', 'at_connect', bs.WeakCall(self._handle_reset_collide)), + ( + 'call', + 'at_connect', + bs.WeakCallStrict(self._handle_reset_collide), + ), ), ) diff --git a/dist/ba_data/python/bascenev1lib/game/conquest.py b/dist/ba_data/python/bascenev1lib/game/conquest.py index ee9c355..da22e76 100644 --- a/dist/ba_data/python/bascenev1lib/game/conquest.py +++ b/dist/ba_data/python/bascenev1lib/game/conquest.py @@ -50,7 +50,7 @@ class Player(bs.Player['Team']): def respawn_timer(self) -> bs.Timer | None: """Type safe access to standard respawn timer.""" val = self.customdata.get('respawn_timer', None) - assert isinstance(val, (bs.Timer, type(None))) + assert isinstance(val, bs.Timer | None) return val @respawn_timer.setter @@ -61,7 +61,7 @@ class Player(bs.Player['Team']): def respawn_icon(self) -> RespawnIcon | None: """Type safe access to standard respawn icon.""" val = self.customdata.get('respawn_icon', None) - assert isinstance(val, (RespawnIcon, type(None))) + assert isinstance(val, RespawnIcon | None) return val @respawn_icon.setter diff --git a/dist/ba_data/python/bascenev1lib/game/easteregghunt.py b/dist/ba_data/python/bascenev1lib/game/easteregghunt.py index 443c77b..3f98c76 100644 --- a/dist/ba_data/python/bascenev1lib/game/easteregghunt.py +++ b/dist/ba_data/python/bascenev1lib/game/easteregghunt.py @@ -213,7 +213,7 @@ class EasterEggHuntGame(bs.TeamGameActivity[Player, Team]): assert self.initialplayerinfos is not None respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 player.respawn_timer = bs.Timer( - respawn_time, bs.Call(self.spawn_player_if_exists, player) + respawn_time, bs.CallStrict(self.spawn_player_if_exists, player) ) player.respawn_icon = RespawnIcon(player, respawn_time) diff --git a/dist/ba_data/python/bascenev1lib/game/elimination.py b/dist/ba_data/python/bascenev1lib/game/elimination.py index 34f1d4a..cb274fd 100644 --- a/dist/ba_data/python/bascenev1lib/game/elimination.py +++ b/dist/ba_data/python/bascenev1lib/game/elimination.py @@ -495,7 +495,7 @@ class EliminationGame(bs.TeamGameActivity[Player, Team]): """Spawn a player (override).""" actor = self.spawn_player_spaz(player, self._get_spawn_point(player)) if not self._solo_mode: - bs.timer(0.3, bs.Call(self._print_lives, player)) + bs.timer(0.3, bs.CallStrict(self._print_lives, player)) # If we have any icons, update their state. for icon in player.icons: @@ -582,8 +582,9 @@ class EliminationGame(bs.TeamGameActivity[Player, Team]): # In solo, put ourself at the back of the spawn order. if self._solo_mode: - player.team.spawn_order.remove(player) - player.team.spawn_order.append(player) + if player in player.team.spawn_order: + player.team.spawn_order.remove(player) + player.team.spawn_order.append(player) def _update(self) -> None: if self._solo_mode: diff --git a/dist/ba_data/python/bascenev1lib/game/football.py b/dist/ba_data/python/bascenev1lib/game/football.py index df0a9fc..563ce62 100644 --- a/dist/ba_data/python/bascenev1lib/game/football.py +++ b/dist/ba_data/python/bascenev1lib/game/football.py @@ -518,8 +518,9 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): @override def on_begin(self) -> None: - # FIXME: Split this up a bit. + # pylint: disable=too-many-locals # pylint: disable=too-many-statements + from bascenev1lib.actor import controlsguide super().on_begin() @@ -572,11 +573,10 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): ) abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite typed_bot_list: list[type[SpazBot]] = [] - self._bot_types_7 = ( - typed_bot_list - + [abot] - + [BomberBot] * (1 if len(self.initialplayerinfos) < 3 else 2) + bomberbots: list[type[SpazBot]] = [BomberBot] * ( + 1 if len(self.initialplayerinfos) < 3 else 2 ) + self._bot_types_7 = typed_bot_list + [abot] + bomberbots bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot self._bot_types_14 = [bbot] * ( 1 if len(self.initialplayerinfos) < 3 else 2 @@ -587,11 +587,9 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot typed_bot_list_2: list[type[SpazBot]] = [] - self._bot_types_initial = ( - typed_bot_list_2 - + [StickyBot] - + [abot] * len(self.initialplayerinfos) - ) + stickybots: list[type[SpazBot]] = [StickyBot] + abots: list[type[SpazBot]] = [abot] * len(self.initialplayerinfos) + self._bot_types_initial = typed_bot_list_2 + stickybots + abots self._bot_types_7 = [bbot] * ( 1 if len(self.initialplayerinfos) < 3 else 2 ) @@ -733,7 +731,8 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): spawnpoints = self.map.powerup_spawn_points for i, _point in enumerate(spawnpoints): bs.timer( - 1.0 + i * 0.5, bs.Call(self._drop_powerup, i, poweruptype) + 1.0 + i * 0.5, + bs.CallStrict(self._drop_powerup, i, poweruptype), ) else: point = ( @@ -837,7 +836,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): bs.setmusic(None) self._bots.final_celebrate() - bs.timer(0.001, bs.Call(self.do_end, 'defeat')) + bs.timer(0.001, bs.CallStrict(self.do_end, 'defeat')) def update_scores(self) -> None: """update scoreboard and check for winners""" @@ -940,13 +939,13 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]): assert self.initialplayerinfos is not None respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 player.respawn_timer = bs.Timer( - respawn_time, bs.Call(self.spawn_player_if_exists, player) + respawn_time, bs.CallStrict(self.spawn_player_if_exists, player) ) player.respawn_icon = RespawnIcon(player, respawn_time) elif isinstance(msg, SpazBotDiedMessage): # Every time a bad guy dies, spawn a new one. - bs.timer(3.0, bs.Call(self._spawn_bot, (type(msg.spazbot)))) + bs.timer(3.0, bs.CallStrict(self._spawn_bot, (type(msg.spazbot)))) elif isinstance(msg, SpazBotPunchedMessage): if self._preset in ['rookie', 'rookie_easy']: diff --git a/dist/ba_data/python/bascenev1lib/game/keepaway.py b/dist/ba_data/python/bascenev1lib/game/keepaway.py index 6fe75ec..d8a11da 100644 --- a/dist/ba_data/python/bascenev1lib/game/keepaway.py +++ b/dist/ba_data/python/bascenev1lib/game/keepaway.py @@ -215,7 +215,7 @@ class KeepAwayGame(bs.TeamGameActivity[Player, Team]): for player in self.players: holdingflag = False try: - assert isinstance(player.actor, (PlayerSpaz, type(None))) + assert isinstance(player.actor, PlayerSpaz | None) if ( player.actor and player.actor.node diff --git a/dist/ba_data/python/bascenev1lib/game/kingofthehill.py b/dist/ba_data/python/bascenev1lib/game/kingofthehill.py index f4375af..7ee2c25 100644 --- a/dist/ba_data/python/bascenev1lib/game/kingofthehill.py +++ b/dist/ba_data/python/bascenev1lib/game/kingofthehill.py @@ -133,12 +133,16 @@ class KingOfTheHillGame(bs.TeamGameActivity[Player, Team]): ( 'call', 'at_connect', - bs.Call(self._handle_player_flag_region_collide, True), + bs.CallStrict( + self._handle_player_flag_region_collide, True + ), ), ( 'call', 'at_disconnect', - bs.Call(self._handle_player_flag_region_collide, False), + bs.CallStrict( + self._handle_player_flag_region_collide, False + ), ), ), ) @@ -192,15 +196,33 @@ class KingOfTheHillGame(bs.TeamGameActivity[Player, Team]): ) # Flag region. flagmats = [self._flag_region_material, shared.region_material] - bs.newnode( - 'region', - attrs={ - 'position': self._flag_pos, - 'scale': (1.8, 1.8, 1.8), - 'type': 'sphere', - 'materials': flagmats, - }, - ) + if self.map.getname() == 'Happy Thoughts': + # Exclusive region for happy thoughts, to avoid + # marking points from the bottom of the platform. + bs.newnode( + 'region', + attrs={ + 'position': ( + self._flag_pos[0], + self._flag_pos[1] * 1.06, + self._flag_pos[2], + ), + 'scale': (3.4, 1.75, 0.8), + 'type': 'square', + 'materials': flagmats, + }, + ) + + else: + bs.newnode( + 'region', + attrs={ + 'position': self._flag_pos, + 'scale': (1.8, 1.8, 1.8), + 'type': 'sphere', + 'materials': flagmats, + }, + ) self._update_scoreboard() self._update_flag_state() diff --git a/dist/ba_data/python/bascenev1lib/game/meteorshower.py b/dist/ba_data/python/bascenev1lib/game/meteorshower.py index 187c558..de400a6 100644 --- a/dist/ba_data/python/bascenev1lib/game/meteorshower.py +++ b/dist/ba_data/python/bascenev1lib/game/meteorshower.py @@ -218,7 +218,7 @@ class MeteorShowerGame(bs.TeamGameActivity[Player, Team]): random.uniform(-3.066, -4.12), 0, ) - bs.timer(delay, bs.Call(self._drop_bomb, pos, vel)) + bs.timer(delay, bs.CallStrict(self._drop_bomb, pos, vel)) delay += 0.1 self._set_meteor_timer() diff --git a/dist/ba_data/python/bascenev1lib/game/onslaught.py b/dist/ba_data/python/bascenev1lib/game/onslaught.py index 8cec75f..6221b2a 100644 --- a/dist/ba_data/python/bascenev1lib/game/onslaught.py +++ b/dist/ba_data/python/bascenev1lib/game/onslaught.py @@ -972,7 +972,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): def _start_powerup_drops(self) -> None: self._powerup_drop_timer = bs.Timer( - 3.0, bs.WeakCall(self._drop_powerups), repeat=True + 3.0, bs.WeakCallStrict(self._drop_powerups), repeat=True ) def _drop_powerups( @@ -984,7 +984,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): for i in range(len(points)): bs.timer( 1.0 + i * 0.5, - bs.WeakCall( + bs.WeakCallStrict( self._drop_powerup, i, poweruptype if i == 0 else None ), ) @@ -1070,7 +1070,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): bs.timer(0, self._cashregistersound.play) bs.timer( base_delay, - bs.WeakCall(self._award_time_bonus, self._time_bonus), + bs.WeakCallStrict(self._award_time_bonus, self._time_bonus), ) base_delay += 1.0 @@ -1082,7 +1082,9 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): have_flawless = True bs.timer( base_delay, - bs.WeakCall(self._award_flawless_bonus, player), + bs.WeakCallStrict( + self._award_flawless_bonus, player + ), ) player.has_been_hurt = False # reset if have_flawless: @@ -1094,7 +1096,9 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): ) self.celebrate(20.0) self._award_completion_achievements() - bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus)) + bs.timer( + base_delay, bs.WeakCallStrict(self._award_completion_bonus) + ) base_delay += 0.85 self._winsound.play() bs.cameraflash() @@ -1104,7 +1108,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): # Can't just pass delay to do_end because our extra bonuses # haven't been added yet (once we call do_end the score # gets locked in). - bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory')) + bs.timer(base_delay, bs.WeakCallStrict(self.do_end, 'victory')) return self._wavenum += 1 @@ -1112,7 +1116,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): # Short celebration after waves. if self._wavenum > 1: self.celebrate(0.5) - bs.timer(base_delay, bs.WeakCall(self._start_next_wave)) + bs.timer(base_delay, bs.WeakCallStrict(self._start_next_wave)) def _award_completion_bonus(self) -> None: self._cashregistersound.play() @@ -1166,7 +1170,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): def _start_time_bonus_timer(self) -> None: self._time_bonus_timer = bs.Timer( - 1.0, bs.WeakCall(self._update_time_bonus), repeat=True + 1.0, bs.WeakCallStrict(self._update_time_bonus), repeat=True ) def _update_player_spawn_info(self) -> None: @@ -1240,7 +1244,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): point = info.point if point is not None: assert bot_type_2 is not None - spcall = bs.WeakCall( + spcall = bs.WeakCallStrict( self.add_bot_at_point, point, bot_type_2, spawn_time ) bs.timer(tval, spcall) @@ -1249,7 +1253,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): spacing = info.spacing bot_angle += spacing * 0.5 if bot_type_2 is not None: - tcall = bs.WeakCall( + tcall = bs.WeakCallStrict( self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time ) bs.timer(tval, tcall) @@ -1259,7 +1263,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): # We can end the wave after all the spawning happens. bs.timer( tval + spawn_time - dtime + 0.01, - bs.WeakCall(self._set_can_end_wave), + bs.WeakCallStrict(self._set_can_end_wave), ) def _start_next_wave(self) -> None: @@ -1318,7 +1322,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): ) ) - bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer)) + bs.timer(5.0, bs.WeakCallStrict(self._start_time_bonus_timer)) wtcolor = (1, 1, 1, 1) wttxt = bs.Lstr( value='${A} ${B}', @@ -1510,7 +1514,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): def _start_updating_waves(self) -> None: self._wave_update_timer = bs.Timer( - 2.0, bs.WeakCall(self._update_waves), repeat=True + 2.0, bs.WeakCallStrict(self._update_waves), repeat=True ) def _update_scores(self) -> None: @@ -1606,7 +1610,8 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): self._tnt_kills += 1 if self._tnt_kills >= 6: bs.timer( - 0.5, bs.WeakCall(self._award_achievement, 'TNT Terror') + 0.5, + bs.WeakCallStrict(self._award_achievement, 'TNT Terror'), ) def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: @@ -1616,7 +1621,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): if self._tnt_kills >= 3: bs.timer( 0.5, - bs.WeakCall( + bs.WeakCallStrict( self._award_achievement, 'Boom Goes the Dynamite' ), ) diff --git a/dist/ba_data/python/bascenev1lib/game/race.py b/dist/ba_data/python/bascenev1lib/game/race.py index 41ff4f6..aa48c29 100644 --- a/dist/ba_data/python/bascenev1lib/game/race.py +++ b/dist/ba_data/python/bascenev1lib/game/race.py @@ -649,7 +649,8 @@ class RaceGame(bs.TeamGameActivity[Player, Team]): pos[2] + random.uniform(*z_range), ) bs.timer( - random.uniform(0.0, 2.0), bs.WeakCall(self._spawn_bomb_at_pos, pos) + random.uniform(0.0, 2.0), + bs.WeakCallStrict(self._spawn_bomb_at_pos, pos), ) def _spawn_bomb_at_pos(self, pos: Sequence[float]) -> None: @@ -690,7 +691,7 @@ class RaceGame(bs.TeamGameActivity[Player, Team]): assert rmine is not None if not rmine.mine: self._flash_mine(m_index) - bs.timer(0.95, bs.Call(self._make_mine, m_index)) + bs.timer(0.95, bs.CallStrict(self._make_mine, m_index)) @override def spawn_player(self, player: Player) -> bs.Actor: diff --git a/dist/ba_data/python/bascenev1lib/game/runaround.py b/dist/ba_data/python/bascenev1lib/game/runaround.py index ddb14d6..287f7c3 100644 --- a/dist/ba_data/python/bascenev1lib/game/runaround.py +++ b/dist/ba_data/python/bascenev1lib/game/runaround.py @@ -576,7 +576,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): for _i in range(4): bs.timer( delay, - bs.Call( + bs.CallStrict( _safesetattr, self._lives_text.node, 'color', @@ -587,12 +587,14 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): assert self._lives_bg.node bs.timer( delay, - bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5), + bs.CallStrict( + _safesetattr, self._lives_bg.node, 'opacity', 0.5 + ), ) delay += 0.125 bs.timer( delay, - bs.Call( + bs.CallStrict( _safesetattr, self._lives_text.node, 'color', @@ -601,12 +603,14 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): ) bs.timer( delay, - bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0), + bs.CallStrict( + _safesetattr, self._lives_bg.node, 'opacity', 1.0 + ), ) delay += 0.125 bs.timer( delay, - bs.Call( + bs.CallStrict( _safesetattr, self._lives_text.node, 'color', @@ -666,7 +670,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): for i in range(len(points)): bs.timer( 1.0 + i * 0.5, - bs.Call( + bs.CallStrict( self._drop_powerup, i, force_first if i == 0 else None ), ) @@ -697,7 +701,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): def end_game(self) -> None: # (Pylint Bug?) pylint: disable=missing-function-docstring - bs.pushcall(bs.Call(self.do_end, 'defeat')) + bs.pushcall(bs.CallStrict(self.do_end, 'defeat')) bs.setmusic(None) self._player_death_sound.play() @@ -754,7 +758,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): bs.timer(0, self._cashregistersound.play) bs.timer( base_delay, - bs.Call(self._award_time_bonus, self._time_bonus), + bs.CallStrict(self._award_time_bonus, self._time_bonus), ) base_delay += 1.0 @@ -800,7 +804,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): bs.cameraflash() bs.setmusic(bs.MusicType.VICTORY) self._game_over = True - bs.timer(base_delay, bs.Call(self.do_end, 'victory')) + bs.timer(base_delay, bs.CallStrict(self.do_end, 'victory')) return self._wavenum += 1 @@ -1102,7 +1106,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): delay = base_delay delay /= self._get_bot_speed(bot_type) t_sec += delay * 0.5 - tcall = bs.Call( + tcall = bs.CallStrict( self.add_bot_at_point, point, bot_type, @@ -1219,7 +1223,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): spaztype, pos=pos, spawn_time=spawn_time, - on_spawn_call=bs.Call(self._on_bot_spawn, path), + on_spawn_call=bs.CallPartial(self._on_bot_spawn, path), ) def _update_time_bonus(self) -> None: @@ -1347,7 +1351,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]): assert self.initialplayerinfos is not None respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 player.respawn_timer = bs.Timer( - respawn_time, bs.Call(self.spawn_player_if_exists, player) + respawn_time, bs.CallStrict(self.spawn_player_if_exists, player) ) player.respawn_icon = RespawnIcon(player, respawn_time) diff --git a/dist/ba_data/python/bascenev1lib/game/targetpractice.py b/dist/ba_data/python/bascenev1lib/game/targetpractice.py index ae0b3c1..805f1f5 100644 --- a/dist/ba_data/python/bascenev1lib/game/targetpractice.py +++ b/dist/ba_data/python/bascenev1lib/game/targetpractice.py @@ -398,6 +398,6 @@ class Target(bs.Actor): 1, {0.9: self._nodes[2].size, 1.1: [0.0]}, ) - bs.timer(1.1, bs.Call(self.handlemessage, bs.DieMessage())) + bs.timer(1.1, bs.CallStrict(self.handlemessage, bs.DieMessage())) return bullseye diff --git a/dist/ba_data/python/bascenev1lib/game/thelaststand.py b/dist/ba_data/python/bascenev1lib/game/thelaststand.py index 7cca417..ce0d058 100644 --- a/dist/ba_data/python/bascenev1lib/game/thelaststand.py +++ b/dist/ba_data/python/bascenev1lib/game/thelaststand.py @@ -127,8 +127,8 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): # Spit out a few powerups and start dropping more shortly. self._drop_powerups(standard_points=True) - bs.timer(2.0, bs.WeakCall(self._start_powerup_drops)) - bs.timer(0.001, bs.WeakCall(self._start_bot_updates)) + bs.timer(2.0, bs.WeakCallStrict(self._start_powerup_drops)) + bs.timer(0.001, bs.WeakCallStrict(self._start_bot_updates)) self.setup_low_life_warning_sound() self._update_scores() self._tntspawner = TNTSpawner( @@ -155,7 +155,7 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): if len(self.players) > 3: self._update_bots() self._bot_update_timer = bs.Timer( - self._bot_update_interval, bs.WeakCall(self._update_bots) + self._bot_update_interval, bs.WeakCallStrict(self._update_bots) ) def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: @@ -170,7 +170,7 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): def _start_powerup_drops(self) -> None: self._powerup_drop_timer = bs.Timer( - 3.0, bs.WeakCall(self._drop_powerups), repeat=True + 3.0, bs.WeakCallStrict(self._drop_powerups), repeat=True ) def _drop_powerups( @@ -184,7 +184,7 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): for i in range(len(pts)): bs.timer( 1.0 + i * 0.5, - bs.WeakCall( + bs.WeakCallStrict( self._drop_powerup, i, force_first if i == 0 else None ), ) @@ -227,7 +227,7 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): assert self._bot_update_interval is not None self._bot_update_interval = max(0.5, self._bot_update_interval * 0.98) self._bot_update_timer = bs.Timer( - self._bot_update_interval, bs.WeakCall(self._update_bots) + self._bot_update_interval, bs.WeakCallStrict(self._update_bots) ) botspawnpts: list[Sequence[float]] = [ [-5.0, 5.5, -4.14], @@ -346,7 +346,7 @@ class TheLastStandGame(bs.CoopGameActivity[Player, Team]): # Tell our bots to celebrate just to rub it in. self._bots.final_celebrate() bs.setmusic(None) - bs.pushcall(bs.WeakCall(self.do_end, 'defeat')) + bs.pushcall(bs.WeakCallStrict(self.do_end, 'defeat')) def _checkroundover(self) -> None: """End the round if conditions are met.""" diff --git a/dist/ba_data/python/bascenev1lib/mainmenu.py b/dist/ba_data/python/bascenev1lib/mainmenu.py index 760da9a..35d25a5 100644 --- a/dist/ba_data/python/bascenev1lib/mainmenu.py +++ b/dist/ba_data/python/bascenev1lib/mainmenu.py @@ -16,7 +16,9 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any - import bacommon.bs + import bacommon.classic + + from bascenev1lib.actor.spazbot import DemoSpazBotSet class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): @@ -36,12 +38,9 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): self.version: bs.NodeActor | None = None self.beta_info: bs.NodeActor | None = None self.beta_info_2: bs.NodeActor | None = None - self.bottom: bs.NodeActor | None = None - self.vr_bottom_fill: bs.NodeActor | None = None - self.vr_top_fill: bs.NodeActor | None = None - self.terrain: bs.NodeActor | None = None + self.map: bs.Map | None = None + self.bot_sets: list[DemoSpazBotSet] = [] self.trees: bs.NodeActor | None = None - self.bgterrain: bs.NodeActor | None = None self._ts = 0.86 self._language: str | None = None self._update_timer: bs.Timer | None = None @@ -51,8 +50,13 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): @override def on_transition_in(self) -> None: - # pylint: disable=too-many-locals super().on_transition_in() + + from bascenev1lib.maps import ThePad + + ThePad.preload() + self.map = ThePad(main_menu_style=True) + random.seed(123) app = bs.app env = app.env @@ -108,17 +112,8 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): assert self.beta_info.node bs.animate(self.beta_info.node, 'opacity', {1.3: 0, 1.8: 1.0}) - mesh = bs.getmesh('thePadLevel') trees_mesh = bs.getmesh('trees') - bottom_mesh = bs.getmesh('thePadLevelBottom') - color_texture = bs.gettexture('thePadLevelColor') trees_texture = bs.gettexture('treesColor') - bgtex = bs.gettexture('menuBG') - bgmesh = bs.getmesh('thePadBG') - - # Load these last since most platforms don't use them. - vr_bottom_fill_mesh = bs.getmesh('thePadVRFillBottom') - vr_top_fill_mesh = bs.getmesh('thePadVRFillTop') gnode = self.globalsnode gnode.camera_mode = 'rotate' @@ -129,51 +124,6 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): gnode.vignette_outer = (0.45, 0.55, 0.54) gnode.vignette_inner = (0.99, 0.98, 0.98) - self.bottom = bs.NodeActor( - bs.newnode( - 'terrain', - attrs={ - 'mesh': bottom_mesh, - 'lighting': False, - 'reflection': 'soft', - 'reflection_scale': [0.45], - 'color_texture': color_texture, - }, - ) - ) - self.vr_bottom_fill = bs.NodeActor( - bs.newnode( - 'terrain', - attrs={ - 'mesh': vr_bottom_fill_mesh, - 'lighting': False, - 'vr_only': True, - 'color_texture': color_texture, - }, - ) - ) - self.vr_top_fill = bs.NodeActor( - bs.newnode( - 'terrain', - attrs={ - 'mesh': vr_top_fill_mesh, - 'vr_only': True, - 'lighting': False, - 'color_texture': bgtex, - }, - ) - ) - self.terrain = bs.NodeActor( - bs.newnode( - 'terrain', - attrs={ - 'mesh': mesh, - 'color_texture': color_texture, - 'reflection': 'soft', - 'reflection_scale': [0.3], - }, - ) - ) self.trees = bs.NodeActor( bs.newnode( 'terrain', @@ -186,24 +136,12 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): }, ) ) - self.bgterrain = bs.NodeActor( - bs.newnode( - 'terrain', - attrs={ - 'mesh': bgmesh, - 'color': (0.92, 0.91, 0.9), - 'lighting': False, - 'background': True, - 'color_texture': bgtex, - }, - ) - ) self._update_timer = bs.Timer(0.1, self._update, repeat=True) self._update() # Hopefully this won't hitch but lets space these out anyway. - bs.add_clean_frame_callback(bs.WeakCall(self._start_preloads)) + bs.add_clean_frame_callback(bs.WeakCallStrict(self._start_preloads)) random.seed() @@ -721,7 +659,7 @@ class NewsDisplay: # If we're signed in, fetch news immediately. Otherwise wait # until we are signed in. self._fetch_timer: bs.Timer | None = bs.Timer( - 1.0, bs.WeakCall(self._try_fetching_news), repeat=True + 1.0, bs.WeakCallStrict(self._try_fetching_news), repeat=True ) self._try_fetching_news() @@ -835,7 +773,7 @@ class NewsDisplay: ] self._phrase_change_timer = bs.Timer( (self._message_duration + self._message_spacing), - bs.WeakCall(self._change_phrase), + bs.WeakCallStrict(self._change_phrase), repeat=True, ) diff --git a/dist/ba_data/python/bascenev1lib/maps.py b/dist/ba_data/python/bascenev1lib/maps.py index c6aa225..23fcc78 100644 --- a/dist/ba_data/python/bascenev1lib/maps.py +++ b/dist/ba_data/python/bascenev1lib/maps.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Standard maps.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -783,26 +784,47 @@ class ThePad(bs.Map): # fixme should chop this into vr/non-vr sections for efficiency return data - def __init__(self) -> None: + def __init__(self, main_menu_style: bool = False) -> None: super().__init__() shared = SharedObjects.get() self.node = bs.newnode( 'terrain', delegate=self, - attrs={ - 'collision_mesh': self.preloaddata['collision_mesh'], - 'mesh': self.preloaddata['mesh'], - 'color_texture': self.preloaddata['tex'], - 'materials': [shared.footing_material], - }, + attrs=( + { + 'collision_mesh': self.preloaddata['collision_mesh'], + 'mesh': self.preloaddata['mesh'], + 'color_texture': self.preloaddata['tex'], + 'materials': [shared.footing_material], + 'reflection': 'soft', + 'reflection_scale': [0.3], + } + if main_menu_style + else { + 'collision_mesh': self.preloaddata['collision_mesh'], + 'mesh': self.preloaddata['mesh'], + 'color_texture': self.preloaddata['tex'], + 'materials': [shared.footing_material], + } + ), ) self.bottom = bs.newnode( 'terrain', - attrs={ - 'mesh': self.preloaddata['bottom_mesh'], - 'lighting': False, - 'color_texture': self.preloaddata['tex'], - }, + attrs=( + { + 'mesh': self.preloaddata['bottom_mesh'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'], + 'reflection': 'soft', + 'reflection_scale': [0.45], + } + if main_menu_style + else { + 'mesh': self.preloaddata['bottom_mesh'], + 'lighting': False, + 'color_texture': self.preloaddata['tex'], + } + ), ) self.background = bs.newnode( 'terrain', diff --git a/dist/ba_data/python/bascenev1lib/tutorial.py b/dist/ba_data/python/bascenev1lib/tutorial.py index 341ec59..2d09a58 100644 --- a/dist/ba_data/python/bascenev1lib/tutorial.py +++ b/dist/ba_data/python/bascenev1lib/tutorial.py @@ -71,8 +71,8 @@ class ButtonPress: img = a.pickup_image color = a.pickup_image_color elif self._button == 'run': - call = bs.Call(s.on_run, 1.0) - release_call = bs.Call(s.on_run, 0.0) + call = bs.CallStrict(s.on_run, 1.0) + release_call = bs.CallStrict(s.on_run, 0.0) img = None color = None else: @@ -97,11 +97,11 @@ class ButtonPress: if img is not None: bs.timer( self._delay / 1000.0, - bs.Call(_safesetattr, img, 'color', c_bright), + bs.CallStrict(_safesetattr, img, 'color', c_bright), ) bs.timer( self._delay / 1000.0, - bs.Call(_safesetattr, img, 'vr_depth', -30), + bs.CallStrict(_safesetattr, img, 'vr_depth', -30), ) if self._release: if self._delay == 0 and self._release_delay == 0: @@ -113,11 +113,11 @@ class ButtonPress: if img is not None: bs.timer( (self._delay + self._release_delay + 100) / 1000.0, - bs.Call(_safesetattr, img, 'color', color), + bs.CallStrict(_safesetattr, img, 'color', color), ) bs.timer( (self._delay + self._release_delay + 100) / 1000.0, - bs.Call(_safesetattr, img, 'vr_depth', -20), + bs.CallStrict(_safesetattr, img, 'vr_depth', -20), ) @@ -149,7 +149,7 @@ class ButtonRelease: img = a.pickup_image color = a.pickup_image_color elif self._button == 'run': - call = bs.Call(s.on_run, 0.0) + call = bs.CallStrict(s.on_run, 0.0) img = None color = None else: @@ -161,11 +161,11 @@ class ButtonRelease: if img is not None: bs.timer( (self._delay + 100) / 1000.0, - bs.Call(_safesetattr, img, 'color', color), + bs.CallStrict(_safesetattr, img, 'color', color), ) bs.timer( (self._delay + 100 / 1000.0), - bs.Call(_safesetattr, img, 'vr_depth', -20), + bs.CallStrict(_safesetattr, img, 'vr_depth', -20), ) @@ -2429,7 +2429,7 @@ class TutorialActivity(bs.Activity[Player, Team]): # Otherwise try again in a few seconds. else: self._read_entries_timer = bs.Timer( - 3.0, bs.WeakCall(self._read_entries) + 3.0, bs.WeakCallStrict(self._read_entries) ) def _run_next_entry(self) -> None: @@ -2445,13 +2445,13 @@ class TutorialActivity(bs.Activity[Player, Team]): # otherwise just keep going. if result is not None: self._entry_timer = bs.Timer( - result / 1000.0, bs.WeakCall(self._run_next_entry) + result / 1000.0, bs.WeakCallStrict(self._run_next_entry) ) return # Done with these entries.. start over soon. self._read_entries_timer = bs.Timer( - 1.0, bs.WeakCall(self._read_entries) + 1.0, bs.WeakCallStrict(self._read_entries) ) def _update_skip_votes(self) -> None: @@ -2502,15 +2502,17 @@ class TutorialActivity(bs.Activity[Player, Team]): for _i in range(6): bs.timer( t / 1000.0, - bs.Call(setattr, self._skip_text, 'color', (1, 0.5, 0.1)), + bs.CallStrict( + setattr, self._skip_text, 'color', (1, 0.5, 0.1) + ), ) t += incr bs.timer( t / 1000.0, - bs.Call(setattr, self._skip_text, 'color', (1, 1, 0)), + bs.CallStrict(setattr, self._skip_text, 'color', (1, 1, 0)), ) t += incr - bs.timer(6.0, bs.WeakCall(self._revert_confirm)) + bs.timer(6.0, bs.WeakCallStrict(self._revert_confirm)) return player.pressed = True @@ -2546,7 +2548,7 @@ class TutorialActivity(bs.Activity[Player, Team]): bs.InputType.BOMB_PRESS, bs.InputType.PICK_UP_PRESS, ), - bs.Call(self._player_pressed_button, player), + bs.CallStrict(self._player_pressed_button, player), ) @override diff --git a/dist/ba_data/python/batemplatefs/_appsubsystem.py b/dist/ba_data/python/batemplatefs/_appsubsystem.py index 74a6ebd..8ba09bd 100644 --- a/dist/ba_data/python/batemplatefs/_appsubsystem.py +++ b/dist/ba_data/python/batemplatefs/_appsubsystem.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides the TemplateFs App-Subsystem.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/dist/ba_data/python/bauiv1/__init__.py b/dist/ba_data/python/bauiv1/__init__.py index a75bd09..8a06a83 100644 --- a/dist/ba_data/python/bauiv1/__init__.py +++ b/dist/ba_data/python/bauiv1/__init__.py @@ -14,8 +14,6 @@ from __future__ import annotations # pylint: disable=redefined-builtin -import logging - from babase import ( accountlog, AccountV2Handle, @@ -38,6 +36,8 @@ from babase import ( asset_loads_allowed, balog, Call, + CallPartial, + CallStrict, DevConsoleButtonDef, DevConsoleTab, DevConsoleTabEntry, @@ -117,6 +117,8 @@ from babase import ( unlock_all_input, utc_now_cloud, WeakCall, + WeakCallPartial, + WeakCallStrict, workspaces_in_use, ) @@ -139,7 +141,6 @@ from _bauiv1 import ( root_ui_resume_updates, rowwidget, scrollwidget, - set_party_window_open, spinnerwidget, Sound, Texture, @@ -149,11 +150,11 @@ from _bauiv1 import ( widget, widget_by_id, ) -from bauiv1._cloudui import show_cloud_ui_window from bauiv1._keyboard import Keyboard from bauiv1._uitypes import ( uicleanupcheck, RootUIUpdatePause, + UIOpenState, ) from bauiv1._appsubsystem import UIV1AppSubsystem from bauiv1._window import ( @@ -189,6 +190,8 @@ __all__ = [ 'BasicMainWindowState', 'buttonwidget', 'Call', + 'CallPartial', + 'CallStrict', 'DevConsoleButtonDef', 'DevConsoleTab', 'DevConsoleTabEntry', @@ -277,9 +280,7 @@ __all__ = [ 'scrollwidget', 'set_analytics_screen', 'set_low_level_config_value', - 'set_party_window_open', 'set_main_ui_input_device', - 'show_cloud_ui_window', 'shutdown_suppress_begin', 'shutdown_suppress_end', 'Sound', @@ -294,11 +295,14 @@ __all__ = [ 'uibounds', 'uicleanupcheck', 'uilog', + 'UIOpenState', 'UIScale', 'UIV1AppSubsystem', 'unlock_all_input', 'utc_now_cloud', 'WeakCall', + 'WeakCallPartial', + 'WeakCallStrict', 'widget', 'widget_by_id', 'Widget', @@ -313,7 +317,7 @@ __all__ = [ if __debug__: for _mdl in 'babase', '_babase': if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): - logging.warning( + balog.warning( '%s was imported before %s finished importing;' ' should not happen.', __name__, diff --git a/dist/ba_data/python/bauiv1/_appsubsystem.py b/dist/ba_data/python/bauiv1/_appsubsystem.py index 72bc0dd..a34892e 100644 --- a/dist/ba_data/python/bauiv1/_appsubsystem.py +++ b/dist/ba_data/python/bauiv1/_appsubsystem.py @@ -157,6 +157,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): window: bauiv1.MainWindow, *, back_state: MainWindowState | None, + extra_type_id: str = '', from_window: bauiv1.MainWindow | None | bool = True, is_back: bool = False, is_top_level: bool = False, @@ -228,6 +229,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): back_state.is_top_level is None or back_state.is_auxiliary is None or back_state.window_type is None + or back_state.extra_type_id is None ): raise RuntimeError( 'Provided back_state is incomplete.' @@ -292,15 +294,18 @@ class UIV1AppSubsystem(babase.AppSubsystem): assert back_state.is_top_level is not None assert back_state.is_auxiliary is not None assert back_state.window_type is type(window) + assert back_state.extra_type_id is not None window.main_window_back_state = back_state.parent window.main_window_is_top_level = back_state.is_top_level window.main_window_is_auxiliary = back_state.is_auxiliary + window.main_window_extra_type_id = back_state.extra_type_id else: # Store if the window is top-level so we won't complain # later if we go back from it and there's nowhere to go to. window.main_window_is_top_level = is_top_level window.main_window_is_auxiliary = is_auxiliary + window.main_window_extra_type_id = extra_type_id # When navigating forward, generate a back-window-state from # the outgoing window. @@ -354,19 +359,19 @@ class UIV1AppSubsystem(babase.AppSubsystem): winstate.is_top_level = window.main_window_is_top_level winstate.is_auxiliary = window.main_window_is_auxiliary winstate.window_type = type(window) + winstate.extra_type_id = window.main_window_extra_type_id return winstate def save_current_main_window_state(self) -> MainWindowState | None: """Save state for the current window, if any.""" - # Calc a back-state from the current window. + # Calc a state from the current window. current_main_win = self._main_window() if current_main_win is None: - # We currenty only hold weak refs to windows so that - # they are free to die on their own, but we expect - # the main menu window to keep itself alive as long - # as its the main one. Holler if that seems to not - # be happening. + # We currenty only hold weak refs to windows so that they + # are free to die on their own, but we expect the main menu + # window to keep itself alive as long as its the main one. + # Holler if that seems to not be happening. babase.uilog.warning( 'save_current_main_window_state: No old MainWindow found;' ' this should not happen.' @@ -384,6 +389,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): assert state.is_top_level is not None assert state.is_auxiliary is not None assert state.window_type is not None + assert state.extra_type_id is not None win = state.create_window(transition=None) self.set_main_window( @@ -393,6 +399,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): is_auxiliary=state.is_auxiliary, back_state=state.parent, suppress_warning=True, + extra_type_id=state.extra_type_id, ) def should_suppress_window_recreates(self) -> bool: @@ -466,6 +473,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): self, win_type: type[bauiv1.MainWindow], win_create_call: Callable[[], bauiv1.MainWindow], + win_extra_type_id: str = '', ) -> None: """Navigate to or away from an Auxiliary window. @@ -474,12 +482,12 @@ class UIV1AppSubsystem(babase.AppSubsystem): ranking windows that the user might want to visit without losing their place in the regular hierarchy. - Calling this method with a MainWindow of the provided type - already in the stack will back out past it (effectively toggling - the 'side quest' back off). + If an auxiliary window matching the provided type and + extra-type-id exists in the stack, this call will back out past + it (think of it as toggling the side-quest back off). - Calling this method with a *different* auxiliary window in the - stack will back out past that and replace it with this + If a non-matching auxiliary window exists in the stack, this + call will back out past that and replace it with this (effectively ending the old side-quest and starting a new one). """ # pylint: disable=unidiomatic-typecheck @@ -499,8 +507,12 @@ class UIV1AppSubsystem(babase.AppSubsystem): state = current_main_window.main_window_back_state while state is not None: assert state.window_type is not None + assert state.extra_type_id is not None if state.is_auxiliary: - if state.window_type is win_type: + if ( + state.window_type is win_type + and state.extra_type_id == win_extra_type_id + ): aux_matching_state = state else: aux_state = state @@ -518,7 +530,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): current_main_window.main_window_back() return - # If there's an ancestory auxiliary state *not* matching our + # If there's an ancestor auxiliary state *not* matching our # type, crop the state and swap in our new auxiliary UI # (example: poking settings, then poking account, then poking # back should end up where things were before the settings @@ -532,19 +544,22 @@ class UIV1AppSubsystem(babase.AppSubsystem): back_state=aux_state.parent, suppress_warning=True, is_auxiliary=True, + extra_type_id=win_extra_type_id, ) return # Ok, no auxiliary states found. Now if current window is - # auxiliary and the type matches, simply do a back. + # auxiliary and the type/extra-id matches, simply do a back. if ( current_main_window.main_window_is_auxiliary and type(current_main_window) is win_type + and current_main_window.main_window_extra_type_id + == win_extra_type_id ): current_main_window.main_window_back() return - # If current window is auxiliary but type doesn't match, + # If current window is auxiliary but type/extra-id doesn't match, # swap it out for our new auxiliary UI. if current_main_window.main_window_is_auxiliary: self.clear_main_window() @@ -554,15 +569,25 @@ class UIV1AppSubsystem(babase.AppSubsystem): back_state=current_main_window.main_window_back_state, suppress_warning=True, is_auxiliary=True, + extra_type_id=win_extra_type_id, ) return # Ok, no existing auxiliary stuff was found period. Just # navigate forward to this UI. - current_main_window.main_window_replace( - win_create_call, is_auxiliary=True + new_main_win = current_main_window.main_window_replace( + win_create_call, + is_auxiliary=True, + extra_type_id=win_extra_type_id, ) + # We should always be allowed to replace the main win in this + # case. + assert new_main_win is not None + + # Make sure what got made exactly matches the type we were passed. + assert type(new_main_win) is win_type + def _schedule_main_win_recreate(self) -> None: # If there is a timer set already, do nothing. diff --git a/dist/ba_data/python/bauiv1/_cloudui.py b/dist/ba_data/python/bauiv1/_cloudui.py deleted file mode 100644 index 642286c..0000000 --- a/dist/ba_data/python/bauiv1/_cloudui.py +++ /dev/null @@ -1,304 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UIs provided by the cloud (similar-ish to html in concept).""" - -from __future__ import annotations - -import random -from dataclasses import dataclass -from typing import TYPE_CHECKING, override, Annotated - -from efro.dataclassio import ioprepped, IOAttrs -import babase -from bauiv1._window import MainWindow, BasicMainWindowState -import _bauiv1 - - -if TYPE_CHECKING: - from bauiv1._window import MainWindowState - - -def show_cloud_ui_window() -> None: - """Bust out a cloud-ui window.""" - - # Pop up an auxiliary window wherever we are in the nav stack. - babase.app.ui_v1.auxiliary_window_activate( - win_type=CloudUIWindow, - win_create_call=lambda: CloudUIWindow(state=None), - ) - - -@ioprepped -@dataclass -class CloudUIButton: - """Represents a button in a cloud-ui.""" - - -@ioprepped -@dataclass -class CloudUIRow: - """Represents a row in a cloud-ui.""" - - buttons: Annotated[list[CloudUIButton], IOAttrs('b')] - - -@ioprepped -@dataclass -class CloudUIRoot: - """Represents an entire cloud-ui.""" - - title: Annotated[str, IOAttrs('t')] - rows: Annotated[list[CloudUIRow], IOAttrs('r')] - - -class CloudUIWindow(MainWindow): - """An example of a well-behaved main-window.""" - - @dataclass - class _State: - root: CloudUIRoot | None - - def __init__( - self, - state: _State | None, - *, - transition: str | None = 'in_right', - origin_widget: _bauiv1.Widget | None = None, - auxiliary_style: bool = True, - ): - ui = babase.app.ui_v1 - - self._state: CloudUIWindow._State | None = None - - # We want to display differently whether we're an auxiliary - # window or not, but unfortunately that value is not yet - # available until we're added to the main-window-stack so it - # must be explicitly passed in. - self._auxiliary_style = auxiliary_style - - # Calc scale and size for our backing window. For medium & large - # ui-scale we aim for a window small enough to always be fully - # visible on-screen and for small mode we aim for a window big - # enough that we never see the window edges; only the window - # texture covering the whole screen. - uiscale = ui.uiscale - self._width = 1400 if uiscale is babase.UIScale.SMALL else 750 - self._height = 1200 if uiscale is babase.UIScale.SMALL else 500 - scale = ( - 1.5 - if uiscale is babase.UIScale.SMALL - else 1.2 if uiscale is babase.UIScale.MEDIUM else 1.0 - ) - - # Do some fancy math to calculate our visible area; this will be - # limited by the screen size in small mode and our backing size - # otherwise. - screensize = babase.get_virtual_screen_size() - self._vis_width = min(self._width - 100, screensize[0] / scale) - self._vis_height = min(self._height - 100, screensize[1] / scale) - self._vis_top = 0.5 * self._height + 0.5 * self._vis_height - self._vis_left = 0.5 * self._width - 0.5 * self._vis_width - - # Nudge our vis area up a bit when we can see the full backing - # (visual fudge factor). - if uiscale is not babase.UIScale.SMALL: - self._vis_top += 12.0 - - super().__init__( - root_widget=_bauiv1.containerwidget( - size=(self._width, self._height), - toolbar_visibility='menu_full', - toolbar_cancel_button_style=( - 'close' if auxiliary_style else 'back' - ), - scale=scale, - ), - transition=transition, - origin_widget=origin_widget, - # We respond to screen size changes only at small ui-scale; - # in other cases we assume our window remains fully visible - # always (flip to windowed mode and resize the app window to - # confirm this). - refresh_on_screen_size_changes=uiscale is babase.UIScale.SMALL, - ) - # Avoid complaints if nothing is selected under us. - _bauiv1.widget(edit=self._root_widget, allow_preserve_selection=False) - - # Title. - self._title = _bauiv1.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._vis_top - 20), - size=(0, 0), - text='', - color=ui.title_color, - scale=0.9 if uiscale is babase.UIScale.SMALL else 1.0, - # Make sure we avoid overlapping meters in small mode. - maxwidth=(130 if uiscale is babase.UIScale.SMALL else 200), - h_align='center', - v_align='center', - ) - - # For small UI-scale we use the system back/close button; - # otherwise we make our own. - if uiscale is babase.UIScale.SMALL: - _bauiv1.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) - else: - btn = _bauiv1.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|close', - scale=0.8, - position=(self._vis_left - 15, self._vis_top - 30), - size=(50, 50) if auxiliary_style else (60, 55), - extra_touch_border_scale=2.0, - button_type=None if auxiliary_style else 'backSmall', - on_activate_call=self.main_window_back, - autoselect=True, - label=babase.charstr( - babase.SpecialChar.CLOSE - if auxiliary_style - else babase.SpecialChar.BACK - ), - ) - _bauiv1.containerwidget(edit=self._root_widget, cancel_button=btn) - - # Show our vis-area bounds (for debugging). - if bool(True): - # Skip top-left since its always overlapping back/close - # buttons. - if bool(False): - _bauiv1.textwidget( - parent=self._root_widget, - position=(self._vis_left, self._vis_top), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='TL', - h_align='left', - v_align='top', - ) - _bauiv1.textwidget( - parent=self._root_widget, - position=(self._vis_left + self._vis_width, self._vis_top), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='TR', - h_align='right', - v_align='top', - ) - _bauiv1.textwidget( - parent=self._root_widget, - position=(self._vis_left, self._vis_top - self._vis_height), - size=(0, 0), - color=(1, 1, 1, 0.5), - scale=0.5, - text='BL', - h_align='left', - v_align='bottom', - ) - _bauiv1.textwidget( - parent=self._root_widget, - position=( - self._vis_left + self._vis_width, - self._vis_top - self._vis_height, - ), - size=(0, 0), - scale=0.5, - color=(1, 1, 1, 0.5), - text='BR', - h_align='right', - v_align='bottom', - ) - - self._spinner: _bauiv1.Widget | None = _bauiv1.spinnerwidget( - parent=self._root_widget, - position=( - self._vis_left + self._vis_width * 0.5, - self._vis_top - self._vis_height * 0.5, - ), - size=48, - style='bomb', - ) - - if state is not None: - self._set_state(state) - else: - if random.random() < 0.3: - babase.apptimer(1.0, babase.WeakCall(self._on_error_response)) - else: - babase.apptimer(1.0, babase.WeakCall(self._on_response)) - - def _on_error_response(self) -> None: - self._set_state(self._State(None)) - - def _on_response(self) -> None: - self._set_state(self._State(CloudUIRoot(title='Testing', rows=[]))) - - def _set_state(self, state: _State) -> None: - """Set a final state (error or page contents). - - This state may be instantly restored if the window is recreated - (depending on cache lifespan/etc.) - """ - - assert self._state is None - self._state = state - - if self._spinner: - self._spinner.delete() - self._spinner = None - - if self._state.root is None: - _bauiv1.textwidget( - edit=self._title, - literal=False, # Allow Lstr. - text=babase.Lstr(resource='errorText'), - ) - _bauiv1.textwidget( - parent=self._root_widget, - position=( - self._vis_left + 0.5 * self._vis_width, - self._vis_top - 0.5 * self._vis_height, - ), - size=(0, 0), - scale=0.6, - text=babase.Lstr(resource='store.loadErrorText'), - h_align='center', - v_align='center', - ) - else: - _bauiv1.textwidget( - edit=self._title, - literal=True, # Never interpret as Lstr. - text=self._state.root.title, - ) - - @override - def get_main_window_state(self) -> MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - - # IMPORTANT - Pull values from self HERE; if we do it in the - # lambda below it'll keep self alive which will lead to - # 'ui-not-getting-cleaned-up' warnings and memory leaks. - auxiliary_style = self._auxiliary_style - state = self._state - - return BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - state=state, - transition=transition, - origin_widget=origin_widget, - auxiliary_style=auxiliary_style, - ), - ) - - @override - def main_window_should_preserve_selection(self) -> bool: - return True - - @override - def get_main_window_shared_state_id(self) -> str | None: - return 'cloudui' diff --git a/dist/ba_data/python/bauiv1/_hooks.py b/dist/ba_data/python/bauiv1/_hooks.py index 872f872..642b5e7 100644 --- a/dist/ba_data/python/bauiv1/_hooks.py +++ b/dist/ba_data/python/bauiv1/_hooks.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Snippets of code for use by the c++ layer.""" + # (most of these are self-explanatory) # pylint: disable=missing-function-docstring from __future__ import annotations diff --git a/dist/ba_data/python/bauiv1/_uitypes.py b/dist/ba_data/python/bauiv1/_uitypes.py index 3184da7..ffb7698 100644 --- a/dist/ba_data/python/bauiv1/_uitypes.py +++ b/dist/ba_data/python/bauiv1/_uitypes.py @@ -75,3 +75,23 @@ class RootUIUpdatePause: def __del__(self) -> None: _bauiv1.root_ui_resume_updates() + + +class UIOpenState: + """Keeps ui informed that something is open. + + Generally instances of this are assigned as a class member of some + UI class, which will then keep the UI informed upon its death. + + It is valid to have multiple states for one tag; the UI will keep a + tally. + """ + + __slots__ = ['stateid'] + + def __init__(self, stateid: str) -> None: + self.stateid = stateid + _bauiv1.ui_open_state_change(self.stateid, 1) + + def __del__(self) -> None: + _bauiv1.ui_open_state_change(self.stateid, -1) diff --git a/dist/ba_data/python/bauiv1/_window.py b/dist/ba_data/python/bauiv1/_window.py index 7c65a90..0827777 100644 --- a/dist/ba_data/python/bauiv1/_window.py +++ b/dist/ba_data/python/bauiv1/_window.py @@ -100,6 +100,8 @@ class MainWindow(Window): # old ones as back targets. self.main_window_is_auxiliary: bool = False + self.main_window_extra_type_id = '' + self._main_window_transition = transition self._main_window_origin_widget = origin_widget super().__init__( @@ -348,6 +350,7 @@ class MainWindow(Window): assert back_state.is_top_level is not None assert back_state.is_auxiliary is not None assert back_state.window_type is not None + assert back_state.extra_type_id is not None # When leaving an auxiliary window, scale the destination # window in instead of sliding to convey that its more of a @@ -364,6 +367,7 @@ class MainWindow(Window): is_back=True, back_state=back_state, suppress_warning=True, + extra_type_id=back_state.extra_type_id, ) def main_window_replace( @@ -371,6 +375,7 @@ class MainWindow(Window): new_window: MainWindow | Callable[[], MainWindow], back_state: MainWindowState | None = None, is_auxiliary: bool = False, + extra_type_id: str = '', ) -> MainWindow | None: """Replace ourself with a new MainWindow. @@ -449,6 +454,7 @@ class MainWindow(Window): from_window=self, back_state=back_state, is_auxiliary=is_auxiliary, + extra_type_id=extra_type_id, suppress_warning=True, ) return new_window @@ -542,6 +548,7 @@ class MainWindowState: self.is_top_level: bool | None = None self.is_auxiliary: bool | None = None self.window_type: type[MainWindow] | None = None + self.extra_type_id: str | None = None def create_window( self, @@ -572,10 +579,15 @@ class BasicMainWindowState(MainWindowState): ], bauiv1.MainWindow, ], + uiopenstate: bauiv1.UIOpenState | None = None, ) -> None: super().__init__() self.create_call = create_call + # We simply need to hold on to this to keep the ui-open-state + # alive. + self.uiopenstate = uiopenstate + @override def create_window( self, diff --git a/dist/ba_data/python/bauiv1/onscreenkeyboard.py b/dist/ba_data/python/bauiv1/onscreenkeyboard.py index c9f775c..bbb3018 100644 --- a/dist/ba_data/python/bauiv1/onscreenkeyboard.py +++ b/dist/ba_data/python/bauiv1/onscreenkeyboard.py @@ -245,7 +245,9 @@ class OnScreenKeyboardWindow(Window): textcolor=key_textcolor, color=key_color_dark, label=babase.Lstr(resource='spaceKeyText'), - on_activate_call=babase.Call(self._type_char, ' '), + on_activate_call=babase.CallStrict( + self._type_char, ' ' + ), ) # Show change instructions only if we have more than one @@ -359,7 +361,7 @@ class OnScreenKeyboardWindow(Window): _bauiv1.buttonwidget( edit=btn, label=chars[i] if have_char else ' ', - on_activate_call=babase.Call( + on_activate_call=babase.CallStrict( self._type_char, chars[i] if have_char else ' ' ), ) diff --git a/dist/ba_data/python/bauiv1lib/account/link.py b/dist/ba_data/python/bauiv1lib/account/link.py deleted file mode 100644 index d19ed06..0000000 --- a/dist/ba_data/python/bauiv1lib/account/link.py +++ /dev/null @@ -1,198 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for linking accounts.""" - -from __future__ import annotations - -import copy -import time -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class AccountLinkWindow(bui.Window): - """Window for linking accounts.""" - - def __init__(self, origin_widget: bui.Widget | None = None): - plus = bui.app.plus - assert plus is not None - - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' - bg_color = (0.4, 0.4, 0.5) - self._width = 560 - self._height = 420 - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - base_scale = ( - 1.65 - if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.1 - ) - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - transition=transition, - scale=base_scale, - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -10) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) - ) - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - position=(40, self._height - 45), - size=(50, 50), - scale=0.7, - label='', - color=bg_color, - on_activate_call=self._cancel, - autoselect=True, - icon=bui.gettexture('crossOut'), - iconscale=1.2, - ) - maxlinks = plus.get_v1_account_misc_read_val('maxLinkAccounts', 5) - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.56), - size=(0, 0), - text=bui.Lstr( - resource=( - 'accountSettingsWindow.linkAccountsInstructionsNewText' - ), - subs=[('${COUNT}', str(maxlinks))], - ), - maxwidth=self._width * 0.9, - color=bui.app.ui_v1.infotextcolor, - max_height=self._height * 0.6, - h_align='center', - v_align='center', - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._cancel_button - ) - bui.buttonwidget( - parent=self._root_widget, - position=(40, 30), - size=(200, 60), - label=bui.Lstr( - resource='accountSettingsWindow.linkAccountsGenerateCodeText' - ), - autoselect=True, - on_activate_call=self._generate_press, - ) - self._enter_code_button = bui.buttonwidget( - parent=self._root_widget, - position=(self._width - 240, 30), - size=(200, 60), - label=bui.Lstr( - resource='accountSettingsWindow.linkAccountsEnterCodeText' - ), - autoselect=True, - on_activate_call=self._enter_code_press, - ) - - def _generate_press(self) -> None: - from bauiv1lib.account.signin import show_sign_in_prompt - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - bui.screenmessage( - bui.Lstr(resource='gatherWindow.requestingAPromoCodeText'), - color=(0, 1, 0), - ) - plus.add_v1_account_transaction( - { - 'type': 'ACCOUNT_LINK_CODE_REQUEST', - 'expire_time': time.time() + 5, - } - ) - plus.run_v1_account_transactions() - - def _enter_code_press(self) -> None: - from bauiv1lib.sendinfo import SendInfoWindow - - SendInfoWindow( - modal=True, - legacy_code_mode=True, - origin_widget=self._enter_code_button, - ) - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - - def _cancel(self) -> None: - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - - -class AccountLinkCodeWindow(bui.Window): - """Window showing code for account-linking.""" - - def __init__(self, data: dict[str, Any]): - self._width = 350 - self._height = 200 - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - color=(0.45, 0.63, 0.15), - transition='in_scale', - scale=( - 1.8 - if uiscale is bui.UIScale.SMALL - else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - ) - ) - self._data = copy.deepcopy(data) - bui.getsound('cashRegister').play() - bui.getsound('swish').play() - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - scale=0.5, - position=(40, self._height - 40), - size=(50, 50), - label='', - on_activate_call=self.close, - autoselect=True, - color=(0.45, 0.63, 0.15), - icon=bui.gettexture('crossOut'), - iconscale=1.2, - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._cancel_button - ) - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.5), - size=(0, 0), - color=(1.0, 3.0, 1.0), - scale=2.0, - h_align='center', - v_align='center', - text=data['code'], - maxwidth=self._width * 0.85, - ) - - def close(self) -> None: - """close the window""" - bui.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/dist/ba_data/python/bauiv1lib/account/settings.py b/dist/ba_data/python/bauiv1lib/account/settings.py index 900072c..ba3dae7 100644 --- a/dist/ba_data/python/bauiv1lib/account/settings.py +++ b/dist/ba_data/python/bauiv1lib/account/settings.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides UI for account functionality.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -33,6 +34,8 @@ class AccountSettingsWindow(bui.MainWindow): plus = bui.app.plus assert plus is not None + self._uiopenstate = bui.UIOpenState('accountsettings') + self._sign_in_v2_proxy_button: bui.Widget | None = None self._sign_in_device_button: bui.Widget | None = None @@ -49,7 +52,7 @@ class AccountSettingsWindow(bui.MainWindow): self._v1_signed_in = plus.get_v1_account_state() == 'signed_in' self._v1_account_state_num = plus.get_v1_account_state_num() self._check_sign_in_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._can_reset_achievements = False @@ -863,7 +866,9 @@ class AccountSettingsWindow(bui.MainWindow): color=(0.55, 0.5, 0.6), icon=bui.gettexture('settingsIcon'), textcolor=(0.75, 0.7, 0.8), - on_activate_call=bui.WeakCall(self._on_manage_account_press), + on_activate_call=bui.WeakCallStrict( + self._on_manage_account_press + ), ) if first_selectable is None: first_selectable = btn @@ -884,7 +889,9 @@ class AccountSettingsWindow(bui.MainWindow): label=bui.Lstr(resource=f'{self._r}.createAnAccountText'), color=(0.55, 0.5, 0.6), textcolor=(0.75, 0.7, 0.8), - on_activate_call=bui.WeakCall(self._on_create_account_press), + on_activate_call=bui.WeakCallStrict( + self._on_create_account_press + ), ) if first_selectable is None: first_selectable = btn @@ -1100,7 +1107,9 @@ class AccountSettingsWindow(bui.MainWindow): if bui.app.plus is not None: bui.apptimer( 0.15, - bui.Call(bui.app.plus.show_game_service_ui, 'achievements'), + bui.CallStrict( + bui.app.plus.show_game_service_ui, 'achievements' + ), ) else: logging.warning('show_game_service_ui requires plus feature-set.') @@ -1138,7 +1147,9 @@ class AccountSettingsWindow(bui.MainWindow): with plus.accounts.primary: plus.cloud.send_message_cb( bacommon.cloud.ManageAccountMessage(weblocation=weblocation), - on_response=bui.WeakCall(self._on_manage_account_response), + on_response=bui.WeakCallPartial( + self._on_manage_account_response + ), ) def _on_manage_account_response( @@ -1158,7 +1169,9 @@ class AccountSettingsWindow(bui.MainWindow): if bui.app.plus is not None: bui.apptimer( 0.15, - bui.Call(bui.app.plus.show_game_service_ui, 'leaderboards'), + bui.CallStrict( + bui.app.plus.show_game_service_ui, 'leaderboards' + ), ) else: logging.warning('show_game_service_ui requires classic') @@ -1245,7 +1258,7 @@ class AccountSettingsWindow(bui.MainWindow): self._needs_refresh = True # Speed UI updates along. - bui.apptimer(0.1, bui.WeakCall(self._update)) + bui.apptimer(0.1, bui.WeakCallStrict(self._update)) def _sign_out_press(self) -> None: plus = bui.app.plus @@ -1273,7 +1286,7 @@ class AccountSettingsWindow(bui.MainWindow): ) # Speed UI updates along. - bui.apptimer(0.1, bui.WeakCall(self._update)) + bui.apptimer(0.1, bui.WeakCallStrict(self._update)) def _sign_in_press(self, login_type: str | LoginType) -> None: @@ -1305,7 +1318,7 @@ class AccountSettingsWindow(bui.MainWindow): cfg['Auto Account State'] = login_type cfg.commit() self._needs_refresh = True - bui.apptimer(0.1, bui.WeakCall(self._update)) + bui.apptimer(0.1, bui.WeakCallStrict(self._update)) return # V2 login sign-in buttons generally go through adapters. @@ -1313,12 +1326,12 @@ class AccountSettingsWindow(bui.MainWindow): if adapter is not None: self._signing_in_adapter = adapter adapter.sign_in( - result_cb=bui.WeakCall(self._on_adapter_sign_in_result), + result_cb=bui.WeakCallPartial(self._on_adapter_sign_in_result), description='account settings button', ) # Will get 'Signing in...' to show. self._needs_refresh = True - bui.apptimer(0.1, bui.WeakCall(self._update)) + bui.apptimer(0.1, bui.WeakCallStrict(self._update)) else: bui.screenmessage(f'Unsupported login_type: {login_type.name}') @@ -1365,7 +1378,7 @@ class AccountSettingsWindow(bui.MainWindow): # credentials go through and the account name shows up. bui.apptimer( 1.5, - bui.Call( + bui.CallStrict( bui.screenmessage, bui.Lstr( resource=self._r @@ -1376,7 +1389,7 @@ class AccountSettingsWindow(bui.MainWindow): # Speed any UI updates along. self._needs_refresh = True - bui.apptimer(0.1, bui.WeakCall(self._update)) + bui.apptimer(0.1, bui.WeakCallStrict(self._update)) def _v2_proxy_sign_in_press(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/account/signin.py b/dist/ba_data/python/bauiv1lib/account/signin.py index 4cdf885..eaddc8a 100644 --- a/dist/ba_data/python/bauiv1lib/account/signin.py +++ b/dist/ba_data/python/bauiv1lib/account/signin.py @@ -7,7 +7,7 @@ from __future__ import annotations import bauiv1 as bui -def show_sign_in_prompt() -> None: +def show_sign_in_prompt(origin_widget: bui.Widget | None = None) -> None: """Bring up a prompt telling the user they must sign in.""" from bauiv1lib.confirm import ConfirmWindow @@ -17,6 +17,7 @@ def show_sign_in_prompt() -> None: ok_text=bui.Lstr(resource='accountSettingsWindow.signInText'), width=460, height=130, + origin_widget=origin_widget, ) @@ -47,6 +48,7 @@ def _show_account_settings() -> None: from_window=False, # Don't check where we're coming from. is_auxiliary=True, suppress_warning=True, + extra_type_id='', ) # Transition out any previous main window. diff --git a/dist/ba_data/python/bauiv1lib/account/unlink.py b/dist/ba_data/python/bauiv1lib/account/unlink.py deleted file mode 100644 index ef80dad..0000000 --- a/dist/ba_data/python/bauiv1lib/account/unlink.py +++ /dev/null @@ -1,152 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for unlinking accounts.""" - -from __future__ import annotations - -import time -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class AccountUnlinkWindow(bui.Window): - """A window to kick off account unlinks.""" - - def __init__(self, origin_widget: bui.Widget | None = None): - plus = bui.app.plus - assert plus is not None - - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' - bg_color = (0.4, 0.4, 0.5) - self._width = 540 - self._height = 350 - self._scroll_width = 400 - self._scroll_height = 200 - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - base_scale = ( - 2.0 - if uiscale is bui.UIScale.SMALL - else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.1 - ) - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - transition=transition, - scale=base_scale, - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -10) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) - ) - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - position=(30, self._height - 50), - size=(50, 50), - scale=0.7, - label='', - color=bg_color, - on_activate_call=self._cancel, - autoselect=True, - icon=bui.gettexture('crossOut'), - iconscale=1.2, - ) - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.88), - size=(0, 0), - text=bui.Lstr( - resource='accountSettingsWindow.unlinkAccountsInstructionsText' - ), - maxwidth=self._width * 0.7, - color=bui.app.ui_v1.infotextcolor, - h_align='center', - v_align='center', - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._cancel_button - ) - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - position=( - (self._width - self._scroll_width) * 0.5, - self._height - 85 - self._scroll_height, - ), - size=(self._scroll_width, self._scroll_height), - ) - bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) - self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, border=2, margin=0, left_border=10 - ) - - our_login_id = plus.get_v1_account_public_login_id() - if our_login_id is None: - entries = [] - else: - account_infos = plus.get_v1_account_misc_read_val_2( - 'linkedAccounts2', [] - ) - entries = [ - {'name': ai['d'], 'id': ai['id']} - for ai in account_infos - if ai['id'] != our_login_id - ] - - # (avoid getting our selection stuck on an empty column widget) - if not entries: - bui.containerwidget(edit=self._scrollwidget, selectable=False) - for i, entry in enumerate(entries): - txt = bui.textwidget( - parent=self._columnwidget, - selectable=True, - text=entry['name'], - size=(self._scroll_width - 30, 30), - autoselect=True, - click_activate=True, - on_activate_call=bui.Call(self._on_entry_selected, entry), - ) - bui.widget(edit=txt, left_widget=self._cancel_button) - if i == 0: - bui.widget(edit=txt, up_widget=self._cancel_button) - - def _on_entry_selected(self, entry: dict[str, Any]) -> None: - plus = bui.app.plus - assert plus is not None - - bui.screenmessage( - bui.Lstr( - resource='pleaseWaitText', fallback_resource='requestingText' - ), - color=(0, 1, 0), - ) - plus.add_v1_account_transaction( - { - 'type': 'ACCOUNT_UNLINK_REQUEST', - 'accountID': entry['id'], - 'expire_time': time.time() + 5, - } - ) - plus.run_v1_account_transactions() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - - def _cancel(self) -> None: - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) diff --git a/dist/ba_data/python/bauiv1lib/account/v2proxy.py b/dist/ba_data/python/bauiv1lib/account/v2proxy.py index 2ddf981..81b25f5 100644 --- a/dist/ba_data/python/bauiv1lib/account/v2proxy.py +++ b/dist/ba_data/python/bauiv1lib/account/v2proxy.py @@ -103,9 +103,9 @@ class V2ProxySignInWindow(bui.Window): self._connection_wait_timeout_time = time.monotonic() + 10.0 self._update_timer = bui.AppTimer( - 0.371, bui.WeakCall(self._update), repeat=True + 0.371, bui.WeakCallStrict(self._update), repeat=True ) - bui.pushcall(bui.WeakCall(self._update)) + bui.pushcall(bui.WeakCallStrict(self._update)) def _update(self) -> None: @@ -135,7 +135,7 @@ class V2ProxySignInWindow(bui.Window): plus.cloud.send_message_cb( bacommon.cloud.LoginProxyRequestMessage(), - on_response=bui.WeakCall(self._on_proxy_request_response), + on_response=bui.WeakCallPartial(self._on_proxy_request_response), ) self._message_in_flight = True @@ -221,7 +221,8 @@ class V2ProxySignInWindow(bui.Window): self._proxyid = response.proxyid self._proxykey = response.proxykey bui.apptimer( - STATUS_CHECK_INTERVAL_SECONDS, bui.WeakCall(self._ask_for_status) + STATUS_CHECK_INTERVAL_SECONDS, + bui.WeakCallStrict(self._ask_for_status), ) def _show_overlay_sign_in_ui( @@ -282,7 +283,9 @@ class V2ProxySignInWindow(bui.Window): h_align='center', v_align='center', autoselect=True, - on_activate_call=bui.Call(self._copy_link, address_pretty), + on_activate_call=bui.CallStrict( + self._copy_link, address_pretty + ), selectable=True, ) qroffs = 20.0 @@ -306,7 +309,7 @@ class V2ProxySignInWindow(bui.Window): bacommon.cloud.LoginProxyStateQueryMessage( proxyid=self._proxyid, proxykey=self._proxykey ), - on_response=bui.WeakCall(self._got_status), + on_response=bui.WeakCallPartial(self._got_status), ) def _got_status( @@ -340,7 +343,9 @@ class V2ProxySignInWindow(bui.Window): bacommon.cloud.LoginProxyCompleteMessage( proxyid=self._proxyid ), - on_response=bui.WeakCall(self._proxy_complete_response), + on_response=bui.WeakCallPartial( + self._proxy_complete_response + ), ) except CommunicationError: pass @@ -360,7 +365,7 @@ class V2ProxySignInWindow(bui.Window): ): bui.apptimer( STATUS_CHECK_INTERVAL_SECONDS, - bui.WeakCall(self._ask_for_status), + bui.WeakCallStrict(self._ask_for_status), ) def _proxy_complete_response(self, response: None | Exception) -> None: diff --git a/dist/ba_data/python/bauiv1lib/account/viewer.py b/dist/ba_data/python/bauiv1lib/account/viewer.py index 2bf33d7..3e3faec 100644 --- a/dist/ba_data/python/bauiv1lib/account/viewer.py +++ b/dist/ba_data/python/bauiv1lib/account/viewer.py @@ -148,7 +148,7 @@ class AccountViewerWindow(PopupWindow): 'accountID': self._account_id, 'profileID': self._profile_id, }, - callback=bui.WeakCall(self._on_query_response), + callback=bui.WeakCallPartial(self._on_query_response), ) def popup_menu_selected_choice( diff --git a/dist/ba_data/python/bauiv1lib/achievements.py b/dist/ba_data/python/bauiv1lib/achievements.py index 3272a4f..c949aff 100644 --- a/dist/ba_data/python/bauiv1lib/achievements.py +++ b/dist/ba_data/python/bauiv1lib/achievements.py @@ -30,6 +30,8 @@ class AchievementsWindow(bui.MainWindow): assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale + self._uiopenstate = bui.UIOpenState('classicachievements') + self._width = 800 if uiscale is bui.UIScale.SMALL else 550 self._height = ( 450 diff --git a/dist/ba_data/python/bauiv1lib/appinvite.py b/dist/ba_data/python/bauiv1lib/appinvite.py index a4f503d..7f3c050 100644 --- a/dist/ba_data/python/bauiv1lib/appinvite.py +++ b/dist/ba_data/python/bauiv1lib/appinvite.py @@ -150,7 +150,7 @@ class ShowFriendCodeWindow(bui.Window): position=(self._width * 0.5 - 100 + xoffs, 39), autoselect=True, label=bui.Lstr(resource='gatherWindow.emailItText'), - on_activate_call=bui.WeakCall(self._email), + on_activate_call=bui.WeakCallStrict(self._email), ) def _email(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/characterpicker.py b/dist/ba_data/python/bauiv1lib/characterpicker.py index 835f3f0..f9ecc8c 100644 --- a/dist/ba_data/python/bauiv1lib/characterpicker.py +++ b/dist/ba_data/python/bauiv1lib/characterpicker.py @@ -147,7 +147,7 @@ class CharacterPicker(PopupWindow): color=(1, 1, 1), tint_color=tint_color, tint2_color=tint2_color, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._select_character, self._spazzes[index] ), position=pos, @@ -198,8 +198,9 @@ class CharacterPicker(PopupWindow): plus = bui.app.plus assert plus is not None - if plus.get_v1_account_state() != 'signed_in': + if plus.accounts.primary is None: show_sign_in_prompt() + self._transition_out() return if self._delegate is not None: diff --git a/dist/ba_data/python/bauiv1lib/chest.py b/dist/ba_data/python/bauiv1lib/chest.py index 047207d..de42593 100644 --- a/dist/ba_data/python/bauiv1lib/chest.py +++ b/dist/ba_data/python/bauiv1lib/chest.py @@ -10,7 +10,8 @@ import random from typing import override, TYPE_CHECKING from efro.util import strict_partial -import bacommon.bs +import bacommon.classic +import bacommon.displayitem as ditm import bauiv1 as bui if TYPE_CHECKING: @@ -38,6 +39,8 @@ class ChestWindow(bui.MainWindow): # pylint: disable=too-many-statements self._index = index + self._uiopenstate = bui.UIOpenState(f'classicchest{index}') + # Get this loading before we need it. self._quote_bubble_tex = bui.gettexture('quoteBubble') @@ -56,7 +59,9 @@ class ChestWindow(bui.MainWindow): self._time_string_timer: bui.AppTimer | None = None self._time_string_text: bui.Widget | None = None self._open_me_flash_timer: bui.AppTimer | None = None - self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = [] + self._prizesets: list[ + bacommon.classic.ChestInfoResponse.Chest.PrizeSet + ] = [] self._prizeindex = -1 self._prizesettxts: dict[int, list[bui.Widget]] = {} self._prizesetimgs: dict[int, list[bui.Widget]] = {} @@ -199,8 +204,8 @@ class ChestWindow(bui.MainWindow): self._action_in_flight = True with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.ChestInfoMessage(chest_id=str(self._index)), - on_response=bui.WeakCall(self._on_chest_info_response), + bacommon.classic.ChestInfoMessage(chest_id=str(self._index)), + on_response=bui.WeakCallPartial(self._on_chest_info_response), ) @override @@ -222,7 +227,7 @@ class ChestWindow(bui.MainWindow): def main_window_should_preserve_selection(self) -> bool: # This doesn't really benefit us since we do lots of widget # creates/destroys throughout our lifetime and also we're an - # auxliary window so should never need to restore toolbar + # auxiliary window so should never need to restore toolbar # selections. return False @@ -241,7 +246,7 @@ class ChestWindow(bui.MainWindow): bui.textwidget(edit=self._time_string_text, text=tstr) def _on_chest_info_response( - self, response: bacommon.bs.ChestInfoResponse | Exception + self, response: bacommon.classic.ChestInfoResponse | Exception ) -> None: assert self._action_in_flight # Should be us. self._action_in_flight = False @@ -261,7 +266,7 @@ class ChestWindow(bui.MainWindow): self._show_chest_actions(response.user_tokens, response.chest) def _on_chest_action_response( - self, response: bacommon.bs.ChestActionResponse | Exception + self, response: bacommon.cloud.ChestActionResponse | Exception ) -> None: assert self._action_in_flight # Should be us. self._action_in_flight = False @@ -296,7 +301,7 @@ class ChestWindow(bui.MainWindow): bui.app.classic.run_bs_client_effects(response.effects, delay=toffs) def _show_chest_actions( - self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest + self, user_tokens: int, chest: bacommon.classic.ChestInfoResponse.Chest ) -> None: """Show state for our chest.""" # pylint: disable=too-many-statements @@ -342,11 +347,7 @@ class ChestWindow(bui.MainWindow): tint2_color=self._chestdisplayinfo.tint2, ) - # Store the prize-sets so we can display odds/etc. Sort them - # with smallest weights first (higher visually == better). - # self._prizesets = sorted( - # chest.prizesets, key=lambda s: s.weight, reverse=True - # ) + # Store the prize-sets so we can display odds/etc. self._prizesets = chest.prizesets if chest.unlock_tokens > 0: @@ -391,7 +392,9 @@ class ChestWindow(bui.MainWindow): self._time_string_timer = bui.AppTimer( 1.0, repeat=True, - call=bui.WeakCall(self._update_time_display, chest.unlock_time), + call=bui.WeakCallStrict( + self._update_time_display, chest.unlock_time + ), ) # Allow watching an ad IF the server tells us we can AND we have @@ -418,7 +421,7 @@ class ChestWindow(bui.MainWindow): label='', button_type='square', autoselect=True, - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._open_press, user_tokens, chest.unlock_tokens ), enable_sound=False, @@ -465,38 +468,41 @@ class ChestWindow(bui.MainWindow): v_align='center', ) ) - self._open_now_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(iconsize, iconsize), - position=( - self._width * 0.5 - iconsize * 0.5 + boffsx, - self._yoffs + bposy + bheight * 0.35, - ), - draw_controller=self._open_now_button, - texture=bui.gettexture('coin'), + if bool(False): + pass + else: + self._open_now_images.append( + bui.imagewidget( + parent=self._root_widget, + size=(iconsize, iconsize), + position=( + self._width * 0.5 - iconsize * 0.5 + boffsx, + self._yoffs + bposy + bheight * 0.35, + ), + draw_controller=self._open_now_button, + texture=bui.gettexture('coin'), + ) ) - ) - self._open_now_texts.append( - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr( - resource='tokens.numTokensText', - subs=[('${COUNT}', str(chest.unlock_tokens))], - ), - position=( - self._width * 0.5 + boffsx, - self._yoffs + bposy + bheight * 0.25, - ), - scale=0.65, - color=(0, 1, 0), - draw_controller=self._open_now_button, - maxwidth=bwidth * 0.8, - size=(0, 0), - h_align='center', - v_align='center', + self._open_now_texts.append( + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr( + resource='tokens.numTokensText', + subs=[('${COUNT}', str(chest.unlock_tokens))], + ), + position=( + self._width * 0.5 + boffsx, + self._yoffs + bposy + bheight * 0.25, + ), + scale=0.65, + color=(0, 1, 0), + draw_controller=self._open_now_button, + maxwidth=bwidth * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + ) ) - ) self._open_now_spinner = bui.spinnerwidget( parent=self._root_widget, position=( @@ -531,7 +537,7 @@ class ChestWindow(bui.MainWindow): label='', button_type='square', autoselect=True, - on_activate_call=bui.WeakCall(self._watch_ad_press), + on_activate_call=bui.WeakCallStrict(self._watch_ad_press), enable_sound=False, ) bui.imagewidget( @@ -595,7 +601,7 @@ class ChestWindow(bui.MainWindow): self._open_me_flash_timer = bui.AppTimer( 0.05, repeat=True, - call=bui.WeakCall(self._open_me_backing_update), + call=bui.WeakCallStrict(self._open_me_backing_update), ) self._open_me_widgets.clear() self._open_me_widgets.append(self._open_me_backing) @@ -608,7 +614,6 @@ class ChestWindow(bui.MainWindow): value='*${A}', subs=[('${A}', bui.Lstr(resource='openMeText'))], ), - # text=bui.Lstr(resource='openMeText'), maxwidth=175, scale=0.7, color=(0, 1.0, 0.7, 1), @@ -644,7 +649,9 @@ class ChestWindow(bui.MainWindow): color=(0.0, 0.8, 0.5), autoselect=True, text_flatness=1.0, - on_activate_call=bui.WeakCall(self._stop_showing_open_me_press), + on_activate_call=bui.WeakCallStrict( + self._stop_showing_open_me_press + ), ) # Avoid depth issues with the quote-bubble image. bui.widget(edit=btn, depth_range=(0.1, 1.0)) @@ -794,10 +801,13 @@ class ChestWindow(bui.MainWindow): for item in p.contents: x += 5.0 - if isinstance(item.item, bacommon.bs.TicketsDisplayItem): + if isinstance(item.item, ditm.Tickets): _mktxt(str(item.item.count)) _mkicon('tickets') - elif isinstance(item.item, bacommon.bs.TokensDisplayItem): + elif isinstance(item.item, ditm.PurpleTickets): + _mktxt(str(item.item.count)) + _mkicon('ticketsPurple') + elif isinstance(item.item, ditm.Tokens): _mktxt(str(item.item.count)) _mkicon('coin') else: @@ -807,7 +817,7 @@ class ChestWindow(bui.MainWindow): descfin = bui.Lstr( translate=('serverResponses', item.description) ).evaluate() - subs = ( + subs: list[str] = ( [] if item.description_subs is None else item.description_subs @@ -845,7 +855,7 @@ class ChestWindow(bui.MainWindow): # Hack: We disable normal swish for the open button and it # seems weird without a swish here, so explicitly do one. bui.getsound('swish').play() - show_get_tokens_prompt() + show_get_tokens_prompt(origin_widget=self._open_now_button) return self._action_in_flight = True @@ -857,12 +867,12 @@ class ChestWindow(bui.MainWindow): with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.ChestActionMessage( + bacommon.cloud.ChestActionMessage( chest_id=str(self._index), - action=bacommon.bs.ChestActionMessage.Action.UNLOCK, + action=bacommon.cloud.ChestActionMessage.Action.UNLOCK, token_payment=token_payment, ), - on_response=bui.WeakCall(self._on_chest_action_response), + on_response=bui.WeakCallPartial(self._on_chest_action_response), ) # Convey that something is in progress. @@ -898,7 +908,7 @@ class ChestWindow(bui.MainWindow): self._action_in_flight = True bui.app.plus.ads.show_ad_2( 'reduce_chest_wait', - on_completion_call=bui.WeakCall(self._watch_ad_complete), + on_completion_call=bui.WeakCallPartial(self._watch_ad_complete), ) # Convey that something is in progress. @@ -937,12 +947,12 @@ class ChestWindow(bui.MainWindow): with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.ChestActionMessage( + bacommon.cloud.ChestActionMessage( chest_id=str(self._index), - action=bacommon.bs.ChestActionMessage.Action.AD, + action=bacommon.cloud.ChestActionMessage.Action.AD, token_payment=0, ), - on_response=bui.WeakCall(self._on_chest_action_response), + on_response=bui.WeakCallPartial(self._on_chest_action_response), ) def _reset(self) -> None: @@ -972,9 +982,14 @@ class ChestWindow(bui.MainWindow): text=bui.Lstr(resource='chests.slotDescriptionText'), color=(1, 1, 1), ) + # This is somewhat redundant with the close button, but we need + # to have *something* selectable in our window for SMALL ui-mode + # (when we don't have our own close button) otherwise we can be + # left unable to select anything. + self._show_done_button(use_ok_label=True) def _show_chest_contents( - self, response: bacommon.bs.ChestActionResponse + self, response: bacommon.cloud.ChestActionResponse ) -> float: # pylint: disable=too-many-locals # pylint: disable=too-many-statements @@ -989,11 +1004,7 @@ class ChestWindow(bui.MainWindow): # Insert test items for testing. if bool(False): - response.contents += [ - bacommon.bs.DisplayItemWrapper.for_display_item( - bacommon.bs.TestDisplayItem() - ) - ] + response.contents += [ditm.Wrapper.for_item(ditm.Test())] tincr = 0.4 tendoffs = tincr * 4.0 @@ -1035,7 +1046,7 @@ class ChestWindow(bui.MainWindow): sign = -sign bui.apptimer( toffs, - bui.Call( + bui.CallStrict( _set_img, x=( 20.0 @@ -1082,7 +1093,7 @@ class ChestWindow(bui.MainWindow): ) toffsopen = toffs - bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening)) + bui.apptimer(toffs, bui.WeakCallStrict(self._show_chest_opening)) toffs += tincr * 1.0 width = xspacing * 0.95 @@ -1102,11 +1113,12 @@ class ChestWindow(bui.MainWindow): self._yoffs - 250.0, ), width=width, + debug=False, ), ) xoffs += xspacing toffs += tincr - bui.apptimer(toffs, bui.WeakCall(self._show_done_button)) + bui.apptimer(toffs, bui.WeakCallStrict(self._show_done_button)) self._show_odds(initial_highlighted_row=-1) @@ -1126,7 +1138,7 @@ class ChestWindow(bui.MainWindow): while toffs2 > 0.0: bui.apptimer( toffs2, - bui.WeakCall(self._highlight_odds_row, i), + bui.WeakCallStrict(self._highlight_odds_row, i), ) toffs2 -= amt if ease_out: @@ -1184,7 +1196,7 @@ class ChestWindow(bui.MainWindow): # comes to rest before scale. bui.apptimer( toffs, - bui.Call( + bui.CallStrict( _set_img, x=( 1.0 @@ -1204,7 +1216,7 @@ class ChestWindow(bui.MainWindow): initial_highlighted_extra=True, ) - def _show_done_button(self) -> None: + def _show_done_button(self, use_ok_label: bool = False) -> None: # No-op if our ui is dead. if not self._root_widget: return @@ -1219,33 +1231,10 @@ class ChestWindow(bui.MainWindow): self._yoffs - 350, ), size=(bwidth, bheight), - label=bui.Lstr(resource='doneText'), + label=bui.Lstr(resource='okText' if use_ok_label else 'doneText'), autoselect=True, on_activate_call=self.main_window_back, ) bui.containerwidget( edit=self._root_widget, selected_child=btn, start_button=btn ) - - -# Slight hack: we define window different classes for our different -# chest slots so that the default UI behavior is to replace each other -# when different ones are pressed. If they are all the same window class -# then the default behavior for such presses is to toggle the existing -# one back off. - - -class ChestWindow0(ChestWindow): - """Child class of ChestWindow for slighty hackish reasons.""" - - -class ChestWindow1(ChestWindow): - """Child class of ChestWindow for slighty hackish reasons.""" - - -class ChestWindow2(ChestWindow): - """Child class of ChestWindow for slighty hackish reasons.""" - - -class ChestWindow3(ChestWindow): - """Child class of ChestWindow for slighty hackish reasons.""" diff --git a/dist/ba_data/python/bauiv1lib/colorpicker.py b/dist/ba_data/python/bauiv1lib/colorpicker.py index 476e059..b557def 100644 --- a/dist/ba_data/python/bauiv1lib/colorpicker.py +++ b/dist/ba_data/python/bauiv1lib/colorpicker.py @@ -12,8 +12,6 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Sequence -REQUIRE_PRO = False - class ColorPicker(PopupWindow): """A popup UI to select from a set of colors. @@ -87,7 +85,7 @@ class ColorPicker(PopupWindow): size=(35, 40), label='', button_type='square', - on_activate_call=bui.WeakCall(self._select, x, y), + on_activate_call=bui.WeakCallStrict(self._select, x, y), autoselect=True, color=color, extra_touch_border_scale=0.0, @@ -105,19 +103,9 @@ class ColorPicker(PopupWindow): fallback_resource='coopSelectWindow.customText', ), autoselect=True, - on_activate_call=bui.WeakCall(self._select_other), + on_activate_call=bui.WeakCallStrict(self._select_other), ) - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): - bui.imagewidget( - parent=self.root_widget, - position=(50, 12), - size=(30, 30), - texture=bui.gettexture('lock'), - draw_controller=other_button, - ) - # If their color is close to one of our swatches, select it. # Otherwise select 'other'. if closest_dist < 0.03: @@ -135,14 +123,6 @@ class ColorPicker(PopupWindow): return self._tag def _select_other(self) -> None: - from bauiv1lib import purchase - - # Requires pro. - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): - purchase.PurchaseWindow(items=['pro']) - self._transition_out() - return ColorPickerExact( parent=self._parent, position=self._position, @@ -282,7 +262,7 @@ class ColorPickerExact(PopupWindow): label=b_label, autoselect=True, enable_sound=False, - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._color_change_press, color_name, binc ), ) @@ -296,7 +276,7 @@ class ColorPickerExact(PopupWindow): color=(0.6, 0.6, 0.6), textcolor=(0.7, 0.7, 0.7), label=bui.Lstr(resource='doneText'), - on_activate_call=bui.WeakCall(self._transition_out), + on_activate_call=bui.WeakCallStrict(self._transition_out), autoselect=True, ) bui.containerwidget(edit=self.root_widget, start_button=btn) diff --git a/dist/ba_data/python/bauiv1lib/config.py b/dist/ba_data/python/bauiv1lib/config.py index cd28fba..3dde744 100644 --- a/dist/ba_data/python/bauiv1lib/config.py +++ b/dist/ba_data/python/bauiv1lib/config.py @@ -152,7 +152,7 @@ class ConfigNumberEdit: size=(28, 28), label='-', autoselect=True, - on_activate_call=bui.Call(self._down), + on_activate_call=bui.CallStrict(self._down), repeat=True, enable_sound=changesound, ) @@ -163,7 +163,7 @@ class ConfigNumberEdit: size=(28, 28), label='+', autoselect=True, - on_activate_call=bui.Call(self._up), + on_activate_call=bui.CallStrict(self._up), repeat=True, enable_sound=changesound, ) diff --git a/dist/ba_data/python/bauiv1lib/connect.py b/dist/ba_data/python/bauiv1lib/connect.py index 099d698..695e7a1 100644 --- a/dist/ba_data/python/bauiv1lib/connect.py +++ b/dist/ba_data/python/bauiv1lib/connect.py @@ -74,7 +74,7 @@ class ConnectWindow(bui.Window): ) bui.containerwidget(edit=self._root_widget, cancel_button=cancel_button) self._update_timer = bui.AppTimer( - 0.113, bui.WeakCall(self._update), repeat=True + 0.113, bui.WeakCallStrict(self._update), repeat=True ) def _update(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/connectivity.py b/dist/ba_data/python/bauiv1lib/connectivity.py index 4b801d5..a48a702 100644 --- a/dist/ba_data/python/bauiv1lib/connectivity.py +++ b/dist/ba_data/python/bauiv1lib/connectivity.py @@ -100,7 +100,7 @@ class WaitForConnectivityWindow(bui.Window): ) bui.containerwidget(edit=self._root_widget, cancel_button=cancel_button) self._update_timer = bui.AppTimer( - 0.113, bui.WeakCall(self._update), repeat=True + 0.113, bui.WeakCallStrict(self._update), repeat=True ) def _update(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/coop/browser.py b/dist/ba_data/python/bauiv1lib/coop/browser.py index 5ebfe91..1acaac0 100644 --- a/dist/ba_data/python/bauiv1lib/coop/browser.py +++ b/dist/ba_data/python/bauiv1lib/coop/browser.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """UI for browsing available co-op levels/games/etc.""" + # FIXME: Break this up. # pylint: disable=too-many-lines @@ -9,9 +10,11 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, override - +from bacommon.analytics import ClassicAnalyticsEvent import bauiv1 as bui + from bauiv1lib.utils import scroll_fade_top, scroll_fade_bottom +from bauiv1lib.connectivity import wait_for_connectivity if TYPE_CHECKING: from typing import Any @@ -59,8 +62,8 @@ class CoopBrowserWindow(bui.MainWindow): ), ) - # Try to recreate the same number of buttons we had last time so our - # re-selection code works. + # Try to recreate the same number of buttons we had last time so + # our re-selection code works. self._tournament_button_count = app.config.get('Tournament Rows', 0) assert isinstance(self._tournament_button_count, int) @@ -256,7 +259,8 @@ class CoopBrowserWindow(bui.MainWindow): self._subcontainer: bui.Widget | None = None - # Take note of our account state; we'll refresh later if this changes. + # Take note of our account state; we'll refresh later if this + # changes. self._account_state_num = plus.get_v1_account_state_num() # Same for fg/bg state. @@ -264,14 +268,14 @@ class CoopBrowserWindow(bui.MainWindow): self._refresh() - # Even though we might display cached tournament data immediately, we - # don't consider it valid until we've pinged. + # Even though we might display cached tournament data + # immediately, we don't consider it valid until we've pinged. # the server for an update self._tourney_data_up_to_date = False - # If we've got a cached tournament list for our account and info for - # each one of those tournaments, go ahead and display it as a - # starting point. + # If we've got a cached tournament list for our account and info + # for each one of those tournaments, go ahead and display it as + # a starting point. if ( classic.accounts.account_tournament_list is not None and classic.accounts.account_tournament_list[0] @@ -289,7 +293,7 @@ class CoopBrowserWindow(bui.MainWindow): # This will pull new data periodically, update timers, etc. self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() @@ -315,17 +319,15 @@ class CoopBrowserWindow(bui.MainWindow): def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" # pylint: disable=cyclic-import - import bauiv1lib.purchase as _unused1 - import bauiv1lib.coop.gamebutton as _unused2 - import bauiv1lib.confirm as _unused3 - import bauiv1lib.account as _unused4 - import bauiv1lib.league.rankwindow as _unused5 - import bauiv1lib.store.browser as _unused6 - import bauiv1lib.account.viewer as _unused7 - import bauiv1lib.tournamentscores as _unused8 - import bauiv1lib.tournamententry as _unused9 - import bauiv1lib.play as _unused10 - import bauiv1lib.coop.tournamentbutton as _unused11 + import bauiv1lib.coop.gamebutton as _unused1 + import bauiv1lib.confirm as _unused2 + import bauiv1lib.account as _unused3 + import bauiv1lib.league.rankwindow as _unused4 + import bauiv1lib.account.viewer as _unused5 + import bauiv1lib.tournamentscores as _unused6 + import bauiv1lib.tournamententry as _unused7 + import bauiv1lib.play as _unused8 + import bauiv1lib.coop.tournamentbutton as _unused9 def _update(self) -> None: plus = bui.app.plus @@ -356,8 +358,8 @@ class CoopBrowserWindow(bui.MainWindow): self._save_state() self._refresh() - # Also encourage a new tournament query since this will clear out - # our current results. + # Also encourage a new tournament query since this will + # clear out our current results. if not self._doing_tournament_query: self._last_tournament_query_time = None @@ -382,7 +384,9 @@ class CoopBrowserWindow(bui.MainWindow): self._doing_tournament_query = True plus.tournament_query( args={'source': 'coop window refresh', 'numScores': 1}, - callback=bui.WeakCall(self._on_tournament_query_response), + callback=bui.WeakCallPartial( + self._on_tournament_query_response + ), ) # Decrement time on our tournament buttons. @@ -457,7 +461,8 @@ class CoopBrowserWindow(bui.MainWindow): assert bui.app.classic is not None accounts = bui.app.classic.accounts if data is not None: - tournament_data = data['t'] # This used to be the whole payload. + # This used to be the whole payload. + tournament_data = data['t'] self._last_tournament_query_response_time = bui.apptime() else: tournament_data = None @@ -467,7 +472,8 @@ class CoopBrowserWindow(bui.MainWindow): self._tourney_data_up_to_date = True accounts.cache_tournament_info(tournament_data) - # Also cache the current tourney list/order for this account. + # Also cache the current tourney list/order for this + # account. accounts.account_tournament_list = ( plus.get_v1_account_state_num(), [e['tournamentID'] for e in tournament_data], @@ -477,21 +483,11 @@ class CoopBrowserWindow(bui.MainWindow): self._update_for_data(tournament_data) def _set_campaign_difficulty(self, difficulty: str) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow - plus = bui.app.plus assert plus is not None assert bui.app.classic is not None if difficulty != self._campaign_difficulty: - if ( - difficulty == 'hard' - and HARD_REQUIRES_PRO - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return bui.getsound('gunCocking').play() if difficulty not in ('easy', 'hard'): print('ERROR: invalid campaign difficulty:', difficulty) @@ -539,8 +535,12 @@ class CoopBrowserWindow(bui.MainWindow): button_type='square', autoselect=True, enable_sound=False, - on_activate_call=bui.Call(self._set_campaign_difficulty, 'easy'), - on_select_call=bui.Call(self.sel_change, 'campaign', 'easyButton'), + on_activate_call=bui.CallStrict( + self._set_campaign_difficulty, 'easy' + ), + on_select_call=bui.CallStrict( + self.sel_change, 'campaign', 'easyButton' + ), color=( sel_color if self._campaign_difficulty == 'easy' @@ -570,8 +570,12 @@ class CoopBrowserWindow(bui.MainWindow): button_type='square', autoselect=True, enable_sound=False, - on_activate_call=bui.Call(self._set_campaign_difficulty, 'hard'), - on_select_call=bui.Call(self.sel_change, 'campaign', 'hardButton'), + on_activate_call=bui.CallStrict( + self._set_campaign_difficulty, 'hard' + ), + on_select_call=bui.CallStrict( + self.sel_change, 'campaign', 'hardButton' + ), color=( sel_color_hard if self._campaign_difficulty == 'hard' @@ -842,7 +846,7 @@ class CoopBrowserWindow(bui.MainWindow): highlight=False, border_opacity=0.0, color=(0.45, 0.4, 0.5), - on_select_call=bui.Call( + on_select_call=bui.CallStrict( self._on_row_selected, 'tournament' + str(i + 1) ), ) @@ -873,7 +877,7 @@ class CoopBrowserWindow(bui.MainWindow): h, v2, is_last_sel, - on_pressed=bui.WeakCall(self.run_tournament), + on_pressed=bui.WeakCallPartial(self.run_tournament), ) ) v -= 200 @@ -927,7 +931,7 @@ class CoopBrowserWindow(bui.MainWindow): highlight=False, border_opacity=0.0, color=(0.45, 0.4, 0.5), - on_select_call=bui.Call(self._on_row_selected, 'custom'), + on_select_call=bui.CallStrict(self._on_row_selected, 'custom'), ) bui.widget( edit=h_scroll, @@ -1033,6 +1037,12 @@ class CoopBrowserWindow(bui.MainWindow): from efro.util import strict_partial from bauiv1lib.confirm import ConfirmWindow + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.START_COOP_SESSION, extra=game + ) + ) + classic = bui.app.classic assert classic is not None @@ -1055,9 +1065,11 @@ class CoopBrowserWindow(bui.MainWindow): ) -> None: """Run the provided game.""" # pylint: disable=cyclic-import + import bacommon.docui.v1 as dui1 + from bauiv1lib.confirm import ConfirmWindow - from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.account.signin import show_sign_in_prompt + from bauiv1lib.store import StoreUIController plus = bui.app.plus assert plus is not None @@ -1075,21 +1087,44 @@ class CoopBrowserWindow(bui.MainWindow): cancel_button=False, width=460, height=130, + origin_widget=origin_widget, ) return required_purchases = bui.app.classic.required_purchases_for_game(game) + have_requirements = True + # Show pop-up to allow purchasing any required stuff we don't have. for purchase in required_purchases: if not purchase in bui.app.classic.purchases: - if plus.accounts.primary is None: - show_sign_in_prompt() - else: - PurchaseWindow( - items=[purchase], origin_widget=origin_widget + have_requirements = False + + if not have_requirements: + if plus.accounts.primary is None: + show_sign_in_prompt() + else: + # Push a custom store window onto our stack. We + # shouldn't use the standard auxiliary store window + # setup since this isn't a standard store window. + wait_for_connectivity( + on_connected=lambda: self.main_window_replace( + bui.CallStrict( + StoreUIController().create_window, + dui1.Request( + '/', + args={'unlockreqs': required_purchases}, + ), + origin_widget=origin_widget, + auxiliary_style=False, + ), + extra_type_id=( + StoreUIController.get_window_extra_type_id() + ), ) - return + ) + + return self._save_state() @@ -1100,9 +1135,11 @@ class CoopBrowserWindow(bui.MainWindow): """Run the provided tournament game.""" # pylint: disable=too-many-return-statements - from bauiv1lib.purchase import PurchaseWindow + import bacommon.docui.v1 as dui1 + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.tournamententry import TournamentEntryWindow + from bauiv1lib.store import StoreUIController plus = bui.app.plus assert plus is not None @@ -1168,16 +1205,42 @@ class CoopBrowserWindow(bui.MainWindow): # We gotta be missing *something* if its locked. assert required_purchases + have_requirements = True + for purchase in required_purchases: if purchase not in classic.purchases: - if plus.accounts.primary is None: - show_sign_in_prompt() - else: - PurchaseWindow( - items=[purchase], - origin_widget=tournament_button.button, + have_requirements = False + + if not have_requirements: + if plus.accounts.primary is None: + show_sign_in_prompt() + else: + # Push a custom store window onto our stack. We + # shouldn't use the standard auxiliary store + # window setup since this isn't a standard store + # window. + wait_for_connectivity( + on_connected=lambda: self.main_window_replace( + bui.CallStrict( + StoreUIController().create_window, + dui1.Request( + '/', + args={'unlockreqs': required_purchases}, + ), + origin_widget=tournament_button.button, + auxiliary_style=False, + ), + extra_type_id=( + StoreUIController.get_window_extra_type_id() + ), ) - return + ) + + # PurchaseWindow( + # items=[purchase], + # origin_widget=tournament_button.button, + # ) + return if tournament_button.time_remaining <= 0: bui.screenmessage( diff --git a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py index dec0dc8..52b0254 100644 --- a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py @@ -71,7 +71,7 @@ class GameButton: on_activate_call=self._on_press, button_type='square', autoselect=True, - on_select_call=bui.Call(window.sel_change, row, game), + on_select_call=bui.CallStrict(window.sel_change, row, game), ) bui.widget( edit=btn, @@ -188,7 +188,7 @@ class GameButton: # give a quasi-random update increment to spread the load.. self._update_timer = bui.AppTimer( 0.001 * (900 + random.randrange(200)), - bui.WeakCall(self._update), + bui.WeakCallStrict(self._update), repeat=True, ) self._update() diff --git a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py index e08c26d..6df1c80 100644 --- a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py @@ -52,7 +52,7 @@ class TournamentButton: label='', button_type='square', autoselect=True, - on_activate_call=bui.WeakCall(self._pressed), + on_activate_call=bui.WeakCallStrict(self._pressed), ) bui.widget( edit=btn, @@ -322,7 +322,7 @@ class TournamentButton: selectable=True, click_activate=True, autoselect=True, - on_activate_call=bui.WeakCall(self._show_leader), + on_activate_call=bui.WeakCallStrict(self._show_leader), size=(170 / 1.4, 40), h_align='center', text='-', @@ -362,7 +362,7 @@ class TournamentButton: autoselect=True, up_widget=self.current_leader_name_text, text_scale=0.6, - on_activate_call=bui.WeakCall(self._show_scores), + on_activate_call=bui.WeakCallStrict(self._show_scores), ) # We handle reselection manually for these so no ids. bui.widget(edit=self.more_scores_button, allow_preserve_selection=False) @@ -411,7 +411,7 @@ class TournamentButton: flatness=1.0, ) self._lock_update_timer = bui.AppTimer( - 1.03, bui.WeakCall(self._update_lock_state), repeat=True + 1.03, bui.WeakCallStrict(self._update_lock_state), repeat=True ) def _pressed(self) -> None: @@ -510,7 +510,7 @@ class TournamentButton: x_offs2c = x_offs2 + 50 # Fetch prize range and trophy strings. - (pr1, pv1, pr2, pv2, pr3, pv3) = classic.get_tournament_prize_strings( + pr1, pv1, pr2, pv2, pr3, pv3 = classic.get_tournament_prize_strings( entry, include_tickets=False ) diff --git a/dist/ba_data/python/bauiv1lib/discord.py b/dist/ba_data/python/bauiv1lib/discord.py index 40aa34f..f432916 100644 --- a/dist/ba_data/python/bauiv1lib/discord.py +++ b/dist/ba_data/python/bauiv1lib/discord.py @@ -114,7 +114,7 @@ class DiscordWindow(bui.Window): autoselect=True, label=bui.Lstr(resource='discordJoinText'), text_scale=1.0, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( bui.open_url, 'https://ballistica.net/discord' ), ) diff --git a/dist/ba_data/python/bauiv1lib/docui/__init__.py b/dist/ba_data/python/bauiv1lib/docui/__init__.py new file mode 100644 index 0000000..189114b --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/__init__.py @@ -0,0 +1,11 @@ +# Released under the MIT License. See LICENSE for details. +"""Functionality for using doc-ui on top of bauiv1.""" + +from bauiv1lib.docui._controller import DocUIController, DocUILocalAction +from bauiv1lib.docui._window import DocUIWindow + +__all__ = [ + 'DocUIController', + 'DocUIWindow', + 'DocUILocalAction', +] diff --git a/dist/ba_data/python/bauiv1lib/docui/_controller.py b/dist/ba_data/python/bauiv1lib/docui/_controller.py new file mode 100644 index 0000000..a024c26 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/_controller.py @@ -0,0 +1,971 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Controller functionality for DocUI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, assert_never +from dataclasses import dataclass +from enum import Enum +import weakref + +from efro.util import asserttype +from efro.error import CleanError, CommunicationError +from efro.dataclassio import dataclass_to_json, dataclass_from_json +from bacommon.docui import ( + DocUIRequestTypeID, + UnknownDocUIRequest, + DocUIResponseTypeID, + UnknownDocUIResponse, + DocUIWebRequest, + DocUIWebResponse, +) +import bauiv1 as bui + +from bauiv1lib.docui._window import DocUIWindow + +if TYPE_CHECKING: + from typing import Callable + + import bacommon.docui.v1 + from bacommon.docui import DocUIRequest, DocUIResponse + import bacommon.clienteffect as clfx + + from bauiv1lib.docui import v1prep + + +class _WinState(Enum): + """Per-window state.""" + + FETCHING_FRESH_REQUEST = 0 + REDISPLAYING_OLD_STATE = 1 + REFRESHING = 2 + ERRORED = 3 + IDLE = 4 + + +@dataclass +class _WinData: + state: _WinState + refresh_timer: bui.AppTimer | None = None + + +class DocUIController: + """Manages interactions between DocUI clients and servers. + + Can include logic to handle all requests locally or can submit them + to be handled by some server or can do some combination thereof. + """ + + class ErrorType(Enum): + """Types of errors that can occur in request processing.""" + + GENERIC = 'generic' + UNDER_CONSTRUCTION = 'under_construction' + COMMUNICATION_ERROR = 'communication' + NEED_UPDATE = 'need_update' + + def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: + """Handle request fulfillment. + + Expected to be overridden by child classes. + + Be aware that this will always be called in a background thread. + + This method is expected to always return a response, even in the + case of errors. Use + :meth:`~bauiv1lib.docui.DocUIController.error_response()` to + translate error conditions to responses. + + The one exception to this rule (no pun intended) is the + :class:`efro.error.CleanError` exception. This can be raised as a + quick and dirty way to show custom error messages. The code + ``raise CleanError('Something broke.')`` will have the same + effect as ``return self.error_response(custom_message='Something + broke.')``. + """ + raise NotImplementedError() + + def local_action(self, action: DocUILocalAction) -> None: + """Do something locally on behalf of the doc-ui. + + Controller classes can override this to expose named actions + that can be triggered by doc-ui button presses, responses, + etc. + + Of course controllers can also perform arbitrary local actions + alongside their normal request fulfillment; this is simply a way + to do so without needing to provide actual ui pages alongside. + + Be *very* careful and focused with what you expose here, + especially if your doc-ui pages are coming from untrusted + sources. Generally things like launching or joining games are + good candidates for local actions. + """ + + def fulfill_request_web( + self, request: DocUIRequest, url: str + ) -> DocUIResponse: + """Fulfill a request by sending it to a webserver.""" + import bacommon.docui.v1 as dui1 + + import urllib3.util + + if not isinstance(request, dui1.Request): + raise RuntimeError(f'Unsupported docui request: {type(request)}') + + upool = bui.app.net.urllib3pool + + # Allow compressed results. + headers = urllib3.util.make_headers(accept_encoding=True) + + # Bundle our doc-ui request with some extra stuff that might + # be relevant to a remote server (language we're using, etc.). + webrequest = DocUIWebRequest( + doc_ui_request=request, + locale=bui.app.locale.current_locale, + engine_build_number=bui.app.env.engine_build_number, + ) + + try: + # Map docui GET requests to http GET and POST to POST. + if request.method is dui1.RequestMethod.GET: + # For GET we embed the request into a url param. + raw_response = upool.request( + 'GET', + url, + fields={ + 'doc_ui_web_request': dataclass_to_json(webrequest) + }, + headers=headers, + ) + + elif request.method is dui1.RequestMethod.POST: + # for POST we send the webrequest as json in body. + headers['Content-Type'] = 'application/json' + raw_response = upool.request( + 'POST', + url, + headers=headers, + body=dataclass_to_json(webrequest), + ) + elif request.method is dui1.RequestMethod.UNKNOWN: + raise RuntimeError('Unknown request method.') + else: + assert_never(request.method) + + try: + # We use 'lossy' here so response versions or elements + # that we don't know about will come through as + # 'Unknown' types instead of erroring completely. + webresponse = dataclass_from_json( + DocUIWebResponse, raw_response.data.decode(), lossy=True + ) + if ( + webresponse.error is None + and webresponse.doc_ui_response is None + ): + raise RuntimeError( + 'Invalid webresponse includes neither error' + ' nor doc-ui-response.' + ) + except Exception as exc: + bui.netlog.info( + 'Error reading docui web-response.', exc_info=True + ) + raise RuntimeError('Error reading docui web-response.') from exc + + # For now, consider all errors communication errors (should + # result in retry buttons in some cases). Can get more + # specific in the future for cases where retries would not + # help. + if raw_response.status != 200: + + # If the response bundled an error, log it. + if webresponse.error is not None: + bui.netlog.info( + 'doc-ui http request returned error: %s', + webresponse.error, + ) + return self.error_response( + request, self.ErrorType.COMMUNICATION_ERROR + ) + + except Exception: + # For now, consider all errors communication errors (should + # result in retry buttons in some cases). Can get more + # specific in the future for cases where retries would not + # help. + bui.netlog.info('Error in docui http request.', exc_info=True) + return self.error_response( + request, self.ErrorType.COMMUNICATION_ERROR + ) + + assert webresponse.doc_ui_response is not None + return webresponse.doc_ui_response + + def fulfill_request_cloud( + self, request: DocUIRequest, domain: str + ) -> DocUIResponse: + """Fulfill a request by sending it to ballistica's cloud. + + :meta private: + """ + import bacommon.cloud + + try: + plus = bui.app.plus + if plus is None: + raise RuntimeError('Plus not available.') + + account = plus.accounts.primary + if account is not None: + with account: + mresponse = plus.cloud.send_message( + bacommon.cloud.FulfillDocUIRequest( + request=request, domain=domain + ) + ) + else: + mresponse = plus.cloud.send_message( + bacommon.cloud.FulfillDocUIRequest( + request=request, domain=domain + ) + ) + assert isinstance(mresponse, bacommon.cloud.FulfillDocUIResponse) + + return mresponse.response + + except CommunicationError: + # Label comm-errors so we can possibly show retry buttons. + return self.error_response( + request, self.ErrorType.COMMUNICATION_ERROR + ) + except Exception: + return self.error_response(request) + + def error_response( + self, + request: DocUIRequest, + error_type: ErrorType = ErrorType.GENERIC, + custom_message: str | None = None, + ) -> DocUIResponse: + """Build a simple error message page. + + A message is included based on ``error_type``. Pass + ``custom_message`` to override this. + + Messages will be translated to the client language using the + 'serverResponses' Lstr translation category. + """ + import bacommon.docui.v1 as dui1 + + error_msg: bui.Lstr | None = None + error_msg_simple: str | None = None + + status_code = dui1.ResponseStatus.UNKNOWN_ERROR + + if custom_message is not None: + error_msg_simple = custom_message + else: + if error_type is self.ErrorType.GENERIC: + error_msg_simple = 'An error has occurred.' + elif error_type is self.ErrorType.NEED_UPDATE: + error_msg_simple = 'You must update the app to view this.' + elif error_type is self.ErrorType.UNDER_CONSTRUCTION: + error_msg_simple = 'Under construction - check back soon.' + elif error_type is self.ErrorType.COMMUNICATION_ERROR: + status_code = dui1.ResponseStatus.COMMUNICATION_ERROR + error_msg_simple = 'Error talking to server.' + else: + assert_never(error_type) + if error_msg_simple is not None: + error_msg = bui.Lstr( + translate=('serverResponses', error_msg_simple) + ) + assert error_msg is not None + + debug = False + + # Give a retry button for comm-errors on GET requests (POSTs may + # have unintentional side-effects so holding off on those for + # now). + do_retry = ( + isinstance(request, dui1.Request) + and request.method is dui1.RequestMethod.GET + and status_code is dui1.ResponseStatus.COMMUNICATION_ERROR + ) + + return dui1.Response( + status=status_code, + page=dui1.Page( + title=bui.Lstr(resource='errorText').as_json(), + title_is_lstr=True, + center_vertically=True, + rows=[ + dui1.ButtonRow( + buttons=[ + dui1.Button( + bui.Lstr( + resource=( + 'retryText' if do_retry else 'okText' + ) + ).as_json(), + ( + dui1.Replace( + asserttype(request, dui1.Request) + ) + if do_retry + else dui1.Local(close_window=True) + ), + label_is_lstr=True, + default=True, + style=dui1.ButtonStyle.MEDIUM, + size=(130, 50), + padding_left=200, + padding_right=200, + padding_top=100, + decorations=[ + dui1.Text( + error_msg.as_json(), + is_lstr=True, + position=(0, 80), + size=(480, 50), + highlight=False, + debug=debug, + ), + ], + debug=debug, + ), + ], + center_content=True, + debug=debug, + ), + ], + ), + ) + + def create_window( + self, + request: DocUIRequest, + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + auxiliary_style: bool = True, + uiopenstateid: str | None = None, + suppress_win_extra_type_warning: bool = False, + ) -> DocUIWindow: + """Create a new window to handle a request.""" + assert bui.in_logic_thread() + + # Create a shiny new window. + win = DocUIWindow( + self, + request, + transition=transition, + origin_widget=origin_widget, + auxiliary_style=auxiliary_style, + uiopenstateid=uiopenstateid, + suppress_win_extra_type_warning=suppress_win_extra_type_warning, + ) + self._set_win_data(win, _WinData(_WinState.FETCHING_FRESH_REQUEST)) + + # Lock its ui and kick off a bg task to populate it. + win.lock_ui() + bui.app.threadpool.submit_no_wait( + bui.CallStrict( + self._process_request_in_bg, + request, + weakwin=weakref.ref(win), + uiscale=bui.app.ui_v1.uiscale, + scroll_width=win.scroll_width, + scroll_height=win.scroll_height, + idprefix=win.main_window_id_prefix, + immediate=False, + ) + ) + return win + + def save_window_shared_state( + self, window: DocUIWindow, state: dict + ) -> None: + """Called when a window shared state is being saved.""" + del window, state # Unused. + + def restore_window_shared_state( + self, window: DocUIWindow, state: dict + ) -> None: + """Called when a window shared state is being restored.""" + del window, state # Unused. + + @classmethod + def get_window_extra_type_id(cls) -> str: + """Return a string suitable for the ``window_extra_type_id`` arg to + :meth:`~bauiv1.UIV1AppSubsystem.auxiliary_window_activate()`. + + This ensures your doc-ui window is identified distinctly from + other doc-ui windows for navigation purposes. + """ + # Include the full path of our controller class. + return f'docui:{cls.__module__}.{cls.__qualname__}' + + def restore( + self, + win: DocUIWindow, + *, + last_response: DocUIResponse | None, + has_had_response: bool, + ) -> DocUIWindow: + """Restore a window from previous state. + + May immediately display old results or may kick off a new + request. + """ + import bacommon.docui.v1 as dui1 + + assert bui.in_logic_thread() + + explicit_response: DocUIResponse | None = None + explicit_error: DocUIController.ErrorType | None = None + + if last_response is not None: + # Re-prep our restored response so we have something to show + # immediately. We'll then fetch an updated version in the + # background to get the latest version. + explicit_response = last_response + else: + # We have no previous response to restore. Fetch a new one. + + # If the current request is a POST, never auto-refetch. Just + # build an error response. + assert isinstance(win.request, dui1.Request) + if win.request.method is dui1.RequestMethod.POST: + # Do we want a specific error for this? Though this case + # should be rare I think. + explicit_error = self.ErrorType.GENERIC + else: + explicit_error = None + + # We're either errored or redisplaying an old state. + if explicit_error is None: + self._set_win_data(win, _WinData(_WinState.REDISPLAYING_OLD_STATE)) + else: + self._set_win_data(win, _WinData(_WinState.ERRORED)) + + # Lock the ui and kick off this update. + win.lock_ui() + bui.app.threadpool.submit_no_wait( + bui.CallStrict( + self._process_request_in_bg, + win.request, + weakwin=weakref.ref(win), + uiscale=bui.app.ui_v1.uiscale, + scroll_width=win.scroll_width, + scroll_height=win.scroll_height, + idprefix=win.main_window_id_prefix, + # If this window has had a response already, snap things + # in immediately with no transitions. + immediate=has_had_response, + explicit_error=explicit_error, + explicit_response=explicit_response, + ) + ) + return win + + def replace( + self, + win: DocUIWindow, + request: DocUIRequest, + *, + origin_widget: bui.Widget | None = None, + is_refresh: bool = False, + ) -> None: + """Kick off a request to replace existing window contents.""" + import bacommon.docui.v1 as dui1 + + assert bui.in_logic_thread() + + win.request = request + + requesttype = request.get_type_id() + + if requesttype is DocUIRequestTypeID.V1: + assert isinstance(win.request, dui1.Request) + + self._set_win_data( + win, + ( + _WinData( + _WinState.REFRESHING + if is_refresh + else _WinState.FETCHING_FRESH_REQUEST + ) + ), + ) + + # Lock the ui and kick off this update. + win.lock_ui(origin_widget) + bui.app.threadpool.submit_no_wait( + bui.CallStrict( + self._process_request_in_bg, + win.request, + weakwin=weakref.ref(win), + uiscale=bui.app.ui_v1.uiscale, + scroll_width=win.scroll_width, + scroll_height=win.scroll_height, + idprefix=win.main_window_id_prefix, + immediate=True, + ) + ) + elif requesttype is DocUIRequestTypeID.UNKNOWN: + assert isinstance(win.request, UnknownDocUIRequest) + # Got a request type we don't know. Show a 'need a newer + # build' error. + + self._set_win_data(win, _WinData(_WinState.ERRORED)) + + # Lock the ui and kick off this update. + win.lock_ui(origin_widget) + bui.app.threadpool.submit_no_wait( + bui.CallStrict( + self._process_request_in_bg, + win.request, + weakwin=weakref.ref(win), + uiscale=bui.app.ui_v1.uiscale, + scroll_width=win.scroll_width, + scroll_height=win.scroll_height, + idprefix=win.main_window_id_prefix, + immediate=True, + explicit_error=self.ErrorType.NEED_UPDATE, + ) + ) + else: + assert_never(requesttype) + + def run_action( + self, + window: DocUIWindow, + widgetid: str | None, + action: bacommon.docui.v1.Action | None, + is_timed: bool = False, + ) -> None: + """Called when a button is pressed in a v1 ui.""" + # pylint: disable=too-many-branches + # pylint: disable=cyclic-import + + import bacommon.docui.v1 as dui + + assert bui.in_logic_thread() + + # If locked, been and tell them to try again. + if window.locked: + bui.getsound('error').play() + bui.screenmessage( + bui.Lstr(resource='pageRefreshingTryAgainText'), color=(1, 0, 0) + ) + return + + widget: bui.Widget | None + + if widgetid is not None: + # Find the associated button. + widget = bui.widget_by_id(widgetid) + if widget is None: + bui.uilog.warning( + 'DocUI button press widget not found: %s (not expected)', + widgetid, + ) + return + else: + widget = None + + # Play error beeps on buttons with no actions assigned to let + # the user know nothing is supposed to happen. + if action is None: + bui.getsound('error').play() + return + + action_type = action.get_type_id() + + if action_type is dui.ActionTypeID.BROWSE: + assert isinstance(action, dui.Browse) + if is_timed: + # Don't let timers pop up new windows. Untrusted servers + # would have a field-day with this. + bui.uilog.warning( + 'Ignoring BROWSE action (disallowed in timed actions).' + ) + else: + if action.default_sound: + bui.getsound('swish').play() + window.main_window_replace( + lambda: self.create_window( + action.request, + origin_widget=widget, + auxiliary_style=False, + suppress_win_extra_type_warning=True, + ) + ) + + self._run_immediate_effects_and_actions( + client_effects=action.immediate_client_effects, + local_action=action.immediate_local_action, + local_action_args=action.immediate_local_action_args, + widget=widget, + window=window, + is_timed=is_timed, + ) + + elif action_type is dui.ActionTypeID.REPLACE: + assert isinstance(action, dui.Replace) + + # Play default click sound only if this is coming from a + # button. + if widget is not None and action.default_sound: + bui.getsound('click01').play() + + # Force a state save so if our UI gets rebuilt with the same + # IDs we'll wind up with the same selection and whatnot. + window.main_window_save_shared_state() + self.replace(window, action.request, origin_widget=widget) + + self._run_immediate_effects_and_actions( + client_effects=action.immediate_client_effects, + local_action=action.immediate_local_action, + local_action_args=action.immediate_local_action_args, + widget=widget, + window=window, + is_timed=is_timed, + ) + + elif action_type is dui.ActionTypeID.LOCAL: + assert isinstance(action, dui.Local) + if action.default_sound: + if action.close_window: + # Always play close-window swish, even if we don't have + # a source button. + bui.getsound('swish').play() + else: + # Only play click sound if this is coming from a button. + if widget is not None: + bui.getsound('click01').play() + if action.close_window: + window.main_window_back() + + self._run_immediate_effects_and_actions( + client_effects=action.immediate_client_effects, + local_action=action.immediate_local_action, + local_action_args=action.immediate_local_action_args, + widget=widget, + window=window, + is_timed=is_timed, + ) + elif action_type is dui.ActionTypeID.UNKNOWN: + assert isinstance(action, dui.UnknownAction) + bui.screenmessage('Unknown action.', color=(1, 0, 0)) + bui.getsound('error').play() + else: + # Make sure we handle all options. + assert_never(action_type) + + def _run_immediate_effects_and_actions( + self, + *, + client_effects: list[clfx.Effect], + local_action: str | None, + local_action_args: dict | None, + widget: bui.Widget | None, + window: DocUIWindow, + is_timed: bool, + ) -> None: + # We don't allow timed actions to trigger immediate + # client-effects/local-actions. It would be too easy for such + # things to get unintentionally re-triggered when navigating + # back/etc. We only want those to happen due to direct button + # presses or initial (non-refresh) responses, which should keep + # things feeling mostly intentional. + if is_timed: + if client_effects: + bui.uilog.warning( + 'Ignoring client-effects (disallowed in timed actions).' + ) + if local_action is not None: + bui.uilog.warning( + 'Ignoring local-action (disallowed in timed actions).' + ) + return + + if bui.app.classic is not None and client_effects: + bui.app.classic.run_bs_client_effects(client_effects) + if local_action is not None: + try: + self.local_action( + DocUILocalAction( + name=local_action, + args=( + {} + if local_action_args is None + else local_action_args + ), + widget=widget, + window=window, + ) + ) + except Exception: + bui.uilog.exception( + 'Error running local-action %s.', + local_action, + ) + + def _get_win_data(self, window: DocUIWindow) -> _WinData: + val = getattr(window, '_wcdata') + assert isinstance(val, _WinData) + return val + + def _set_win_data(self, window: DocUIWindow, data: _WinData) -> None: + setattr(window, '_wcdata', data) + + def _process_request_in_bg( + self, + request: DocUIRequest, + *, + weakwin: weakref.ref[DocUIWindow], + uiscale: bui.UIScale, + scroll_width: float, + scroll_height: float, + idprefix: str, + immediate: bool, + explicit_error: ErrorType | None = None, + explicit_response: DocUIResponse | None = None, + ) -> None: + """Wrangle a request from within a background thread. + + This will always return a response, even on error conditions. + """ + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + import bacommon.docui.v1 as dui1 + from bauiv1lib.docui import v1prep + + assert not bui.in_logic_thread() + + response: DocUIResponse | None = None + error: DocUIController.ErrorType | None = None + + if explicit_error is not None: + error = explicit_error + elif explicit_response is not None: + response = explicit_response + else: + try: + response = self.fulfill_request(request) + except CleanError as exc: + # The one exception case we officially handle. Translate + # this to an error response with a custom message. + response = self.error_response(request, custom_message=str(exc)) + + except Exception: + # fulfill_request is expected to gracefully return even + # on errors. Make noise if it didn't. + bui.uilog.exception( + 'Error in fulfill_request().\n' + 'It should always return responses; not throw exceptions.\n' + 'Use error_response() when errors occur.', + exc_info=True, + ) + error = self.ErrorType.GENERIC + + # Validate any response we got. + if response is not None: + assert error is None + responsetype = response.get_type_id() + + if responsetype is DocUIResponseTypeID.V1: + + assert isinstance(response, dui1.Response) + + # If they require a build-number newer than us, say so. + minbuild = response.minimum_engine_build + if ( + minbuild is not None + and minbuild > bui.app.env.engine_build_number + ): + error = self.ErrorType.NEED_UPDATE + + elif responsetype is DocUIResponseTypeID.UNKNOWN: + assert isinstance(response, UnknownDocUIResponse) + bui.uilog.debug( + 'Got unsupported docui response.', exc_info=True + ) + error = self.ErrorType.NEED_UPDATE + response = None + else: + # Make sure we cover all types we're aware of. + assert_never(responsetype) + + if error is not None: + response = self.error_response(request, error) + + # Currently must be v1 if it made it to here. + assert isinstance(response, dui1.Response) + + pageprep = v1prep.prep_page( + response.page, + uiscale=uiscale, + scroll_width=scroll_width, + scroll_height=scroll_height, + immediate=immediate, + idprefix=idprefix, + ) + + # Go ahead and just push the response along with our weakref + # back to the logic thread for handling. We could quick-out here + # if the window is dead, but wrangling its refs here could + # theoretically lead to it being deallocated here which could be + # problematic. + bui.pushcall( + bui.CallStrict( + self._handle_response_in_ui_thread, + response, + weakwin, + pageprep, + ), + from_other_thread=True, + ) + + def _handle_response_in_ui_thread( + self, + response: DocUIResponse, + weakwin: weakref.ref[DocUIWindow], + pageprep: v1prep.PagePrep, + ) -> None: + import bacommon.docui.v1 as dui1 + + assert bui.in_logic_thread() + + # If our target window died since we made the request, no + # biggie. + win = weakwin() + if win is None: + return + + # Currently should only be sending ourself v1 responses here. + assert isinstance(response, dui1.Response) + + win.unlock_ui() + win.set_last_response( + response, + response.status == dui1.ResponseStatus.SUCCESS, + ) + + # Set the UI. + win.instantiate_ui(pageprep) + + state = self._get_win_data(win).state + + # Run client-effects and local-actions ONLY after fresh requests + # (don't want sounds and other actions firing when we navigate + # back or resize a window). + if state is _WinState.FETCHING_FRESH_REQUEST: + if response.client_effects and bui.app.classic is not None: + bui.app.classic.run_bs_client_effects(response.client_effects) + if response.local_action is not None: + try: + self.local_action( + DocUILocalAction( + name=response.local_action, + args=( + {} + if response.local_action_args is None + else response.local_action_args + ), + widget=None, + window=win, + ) + ) + except Exception: + bui.uilog.exception( + 'Error running local-action %s.', + response.local_action, + ) + + # Possibly take further action depending on state. + if state is _WinState.REDISPLAYING_OLD_STATE: + # Ok; we're done showing old state. For POST this is as far + # as we go (don't want to repeat POST effects), but for GET + # we can now kick off a refresh to swap in the latest + # version of the page. + assert isinstance(win.request, dui1.Request) + if win.request.method is dui1.RequestMethod.GET: + self.replace(win, win.request, is_refresh=True) + elif ( + win.request.method is dui1.RequestMethod.POST + or win.request.method is dui1.RequestMethod.UNKNOWN + ): + self._set_idle_and_schedule_timed_action(response, weakwin) + else: + assert_never(win.request.method) + + elif state is _WinState.ERRORED or state is _WinState.IDLE: + pass + elif ( + state is _WinState.FETCHING_FRESH_REQUEST + or state is _WinState.REFRESHING + ): + self._set_idle_and_schedule_timed_action(response, weakwin) + else: + assert_never(state) + + def _set_idle_and_schedule_timed_action( + self, response: DocUIResponse, weakwin: weakref.ref[DocUIWindow] + ) -> None: + import bacommon.docui.v1 as dui1 + + win = weakwin() + assert win is not None + assert self._get_win_data(win).state is not _WinState.IDLE + assert isinstance(response, dui1.Response) + + refresh_timer: bui.AppTimer | None = None + + if response.timed_action is not None: + + # Limit delay to .25 seconds or more to prevent excessive + # churn. Can revisit if there is a strong use case. + refresh_timer = bui.AppTimer( + max(0.250, response.timed_action_delay), + bui.WeakCallStrict( + self._run_timed_action, + weakwin, + response.timed_action, + ), + ) + self._set_win_data(win, _WinData(_WinState.IDLE, refresh_timer)) + + def _run_timed_action( + self, + weakwin: weakref.ref[DocUIWindow], + action: bacommon.docui.v1.Action, + ) -> None: + # If our target window died since we set this timer, no biggie. + win = weakwin() + if win is None: + return + + state = self._get_win_data(win).state + if state is not _WinState.IDLE: + bui.uilog.warning( + 'win has non-idle state in _run_timed_action; not expected' + ) + return + self.run_action(win, widgetid=None, action=action, is_timed=True) + + +@dataclass +class DocUILocalAction: + """Context for a local-action.""" + + name: str + args: dict + widget: bui.Widget | None + window: DocUIWindow diff --git a/dist/ba_data/python/bauiv1lib/docui/_window.py b/dist/ba_data/python/bauiv1lib/docui/_window.py new file mode 100644 index 0000000..cd858fd --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/_window.py @@ -0,0 +1,546 @@ +# Released under the MIT License. See LICENSE for details. +# +"""UIs provided by the cloud (similar-ish to html in concept).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override, assert_never + +import bauiv1 as bui + +from bacommon.docui import DocUIRequestTypeID, DocUIResponseTypeID +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top + +if TYPE_CHECKING: + from typing import Callable + + from bacommon.docui import DocUIRequest, DocUIResponse + import bacommon.docui.v1 + from bauiv1lib.docui._controller import DocUIController + from bauiv1lib.docui import v1prep + + +class DocUIWindow(bui.MainWindow): + """Window showing doc-ui content.""" + + def __init__( + self, + controller: DocUIController, + request: DocUIRequest, + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + auxiliary_style: bool = True, + restored: bool = False, + uiopenstateid: str | None = None, + suppress_win_extra_type_warning: bool = False, + has_had_response: bool = False, + ): + # pylint: disable=too-many-statements + ui = bui.app.ui_v1 + + self._uiopenstate = ( + None if uiopenstateid is None else bui.UIOpenState(uiopenstateid) + ) + + self._locked = False + + self._restored = restored + + # Note: our windows and states both hold strong references to + # the controller, so we need to make sure the opposite is not + # true to avoid cycles. + self.controller = controller + + self._suppress_win_extra_type_warning = suppress_win_extra_type_warning + self._request = request + self._request_state_id = self._default_state_id(request) + + self._last_response: DocUIResponse | None = None + self._last_response_success: bool = False + self._last_response_shared_state_id: str | None = None + self._has_had_response: bool = has_had_response + + # We want to display differently whether we're an auxiliary + # window or not, but unfortunately that value is not yet + # available until we're added to the main-window-stack so it + # must be explicitly passed in. + self._auxiliary_style = auxiliary_style + + # Calc scale and size for our backing window. For medium & large + # ui-scale we aim for a window small enough to always be fully + # visible on-screen and for small mode we aim for a window big + # enough that we never see the window edges; only the window + # texture covering the whole screen. + uiscale = ui.uiscale + self._width = ( + 1400 + if uiscale is bui.UIScale.SMALL + else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 + ) + self._height = ( + 1200 + if uiscale is bui.UIScale.SMALL + else 700 if uiscale is bui.UIScale.MEDIUM else 800 + ) + self._root_scale = ( + 1.45 + if uiscale is bui.UIScale.SMALL + else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + + # Do some fancy math to calculate our visible area; this will be + # limited by the screen size in small mode and our backing size + # otherwise. + screensize = bui.get_virtual_screen_size() + self._vis_width = min( + self._width - 150, screensize[0] / self._root_scale + ) + self._vis_height = min( + self._height - 80, screensize[1] / self._root_scale + ) + self._vis_top = 0.5 * self._height + 0.5 * self._vis_height + self._vis_left = 0.5 * self._width - 0.5 * self._vis_width + + self._scroll_width = self._vis_width + self._scroll_left = self._vis_left + 0.5 * ( + self._vis_width - self._scroll_width + ) + # Go with full-screen scrollable aread in small ui. + self._scroll_height = self._vis_height - ( + -1 if uiscale is bui.UIScale.SMALL else 43 + ) + self._scroll_bottom = ( + self._vis_top + - (-1 if uiscale is bui.UIScale.SMALL else 32) + - self._scroll_height + ) + + # Nudge our vis area up a bit when we can see the full backing + # (visual fudge factor). + if uiscale is not bui.UIScale.SMALL: + self._vis_top += 12.0 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility='menu_full', + toolbar_cancel_button_style=( + 'close' if auxiliary_style else 'back' + ), + scale=self._root_scale, + ), + transition=transition, + origin_widget=origin_widget, + # We respond to screen size changes only at small ui-scale; + # in other cases we assume our window remains fully visible + # always (flip to windowed mode and resize the app window to + # confirm this). + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + # Avoid complaints if nothing is selected under us. + bui.widget(edit=self._root_widget, allow_preserve_selection=False) + + self._subcontainer: bui.Widget | None = None + + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + highlight=True, # Will turn off once we have UI. + size=(self._scroll_width, self._scroll_height), + position=(self._scroll_left, self._scroll_bottom), + border_opacity=0.4, + center_small_content_horizontally=True, + claims_left_right=True, + ) + bui.widget(edit=self._scrollwidget, autoselect=True) + + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL and bool(True): + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + + # Title. + self._title = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._vis_top - 20), + size=(0, 0), + text='', + color=ui.title_color, + scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, + # Make sure we avoid overlapping meters in small mode. + maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), + h_align='center', + v_align='center', + ) + # Needed to display properly over scrolled content. + bui.widget(edit=self._title, depth_range=(0.9, 1.0)) + + # For small UI-scale we use the system back/close button; + # otherwise we make our own. + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button: bui.Widget | None = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|close', + scale=0.8, + position=(self._vis_left + 2, self._vis_top - 35), + size=(50, 50) if auxiliary_style else (60, 55), + extra_touch_border_scale=2.0, + button_type=None if auxiliary_style else 'backSmall', + on_activate_call=self.main_window_back, + autoselect=True, + label=bui.charstr( + bui.SpecialChar.CLOSE + if auxiliary_style + else bui.SpecialChar.BACK + ), + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + + # Show our vis-area bounds (for debugging). + if bool(False): + # Skip top-left since its always overlapping back/close + # buttons. + if bool(False): + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TL', + h_align='left', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left + self._vis_width, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TR', + h_align='right', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top - self._vis_height), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='BL', + h_align='left', + v_align='bottom', + ) + bui.textwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width, + self._vis_top - self._vis_height, + ), + size=(0, 0), + scale=0.5, + color=(1, 1, 1, 0.5), + text='BR', + h_align='right', + v_align='bottom', + ) + + self._spinner: bui.Widget | None = None + + if not suppress_win_extra_type_warning: + bui.pushcall(bui.WeakCallStrict(self._sanity_check_win_extra_type)) + + def _sanity_check_win_extra_type(self) -> None: + # There will be lots of windows with this same type, so we really + # need the user to provide extra-type-ids so our logic can tell + # all of us apart for navigation purposes. + if not self.main_window_extra_type_id: + bui.uilog.warning( + '%s created by %s was not assigned an extra-type-id.' + ' Always pass a "win_extra_type_id" when calling' + ' `auxiliary_window_activate()` with a DocUIWindow.', + type(self).__name__, + type(self.controller), + ) + + @property + def request(self) -> DocUIRequest: + """The current request. + + Should only be accessed from the logic thread while the ui is + unlocked. + """ + assert bui.in_logic_thread() + if self._request is None: + raise RuntimeError('No request is set.') + return self._request + + @request.setter + def request(self, request: DocUIRequest) -> None: + assert bui.in_logic_thread() + self._request = request + self._request_state_id = self._default_state_id(request) + + # New requests immediately blow away existing responses. + self._last_response = None + self._last_response_success = False + self._last_response_shared_state_id = None + + @classmethod + def _default_state_id(cls, request: DocUIRequest) -> str: + """Calc a default state id for a request.""" + requesttypeid = request.get_type_id() + if requesttypeid is DocUIRequestTypeID.V1: + import bacommon.docui.v1 as dui1 + + # One state per path seems like a reasonable default. + assert isinstance(request, dui1.Request) + return request.path + if requesttypeid is DocUIRequestTypeID.UNKNOWN: + return 'unknown' + assert_never(requesttypeid) + + def lock_ui(self, origin_widget: bui.Widget | None = None) -> None: + """Stop UI interactions during some operation.""" + assert bui.in_logic_thread() + assert not self._locked + + # If a spinner-position is provided, make the spinner in our + # subcontainer at the provided spot and have it appear + # immediately instead of fading (Makes button presses feel more + # responsive). + parent = None if origin_widget is None else origin_widget.parent + if parent is not None: + assert origin_widget is not None + self._spinner = bui.spinnerwidget( + parent=parent, + position=origin_widget.center, + size=48, + fade=False, + ) + else: + # Otherwise do one at the center of our window (not in our + # subcontainer). + self._spinner = bui.spinnerwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width * 0.5, + self._vis_top - self._vis_height * 0.5, + ), + size=48, + # With restored windows we're likely to have stuff under + # the spinner. Bomb looks nicer but simple is more + # readable in those cases. + style='simple' if self._restored else 'bomb', + ) + self._locked = True + + def unlock_ui(self) -> None: + """Resume normal UI interactions.""" + assert bui.in_logic_thread() + assert self._locked + + if self._spinner: + self._spinner.delete() + self._spinner = None + self._locked = False + + @property + def locked(self) -> bool: + """Are we locked?""" + assert bui.in_logic_thread() + return self._locked + + @property + def scroll_width(self) -> float: + """Width of our scroll area.""" + return self._scroll_width + + @property + def scroll_height(self) -> float: + """Height of our scroll area.""" + return self._scroll_height + + def set_last_response(self, response: DocUIResponse, success: bool) -> None: + """Set a response to a request.""" + assert bui.in_logic_thread() + assert not self._locked + self._last_response = response + self._last_response_success = success + self._has_had_response = True + + # Grab any custom shared-state-id included in this response. + responsetypeid = response.get_type_id() + if responsetypeid is DocUIResponseTypeID.V1: + import bacommon.docui.v1 as dui1 + + assert isinstance(response, dui1.Response) + self._last_response_shared_state_id = response.shared_state_id + elif responsetypeid is DocUIResponseTypeID.UNKNOWN: + self._last_response_shared_state_id = None + else: + assert_never(responsetypeid) + + def instantiate_ui(self, pageprep: v1prep.PagePrep) -> None: + """Replace any current ui with provided prepped one. + + :meta private: + """ + from bauiv1lib.docui.v1prep._calls import ( + doc_ui_v1_instantiate_page_prep, + ) + + assert bui.in_logic_thread() + + # Set title. + bui.textwidget( + edit=self._title, + literal=not pageprep.title_is_lstr, + text=pageprep.title, + ) + + # Clear any existing children. + for child in self._scrollwidget.get_children(): + child.delete() + + if pageprep.rows: + # Stop showing scroll-widget highlights now that we've got + # child stuff to be highlighted. + bui.scrollwidget( + edit=self._scrollwidget, + highlight=False, + simple_culling_v=pageprep.simple_culling_v, + center_small_content=(pageprep.center_vertically), + ) + self._subcontainer = doc_ui_v1_instantiate_page_prep( + pageprep, + rootwidget=self._root_widget, + scrollwidget=self._scrollwidget, + backbutton=( + bui.get_special_widget('back_button') + if self._back_button is None + else self._back_button + ), + windowbackbutton=self._back_button, + window=self, + ) + else: + # No child stuff to show so let the scroll-widget highlight. + bui.scrollwidget( + edit=self._scrollwidget, + highlight=True, + simple_culling_v=0.0, + center_small_content=True, + ) + self._subcontainer = None + + bui.textwidget( + parent=self._scrollwidget, + h_align='center', + v_align='center', + text=bui.Lstr( + translate=('serverResponses', 'There is nothing here.') + ), + scale=0.75, + color=(1, 1, 1, 0.5), + size=(0, 0), + ) + + # Most of our UI won't exist until this point so we need to + # explicitly restore state for selection restore to work. + # + # Note to self: perhaps we should *not* do this if significant + # time has passed since the window was made or if input commands + # have happened. + self.main_window_restore_shared_state() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + assert bui.in_logic_thread() + + # IMPORTANT - Pull values from self HERE; if we do it in the + # lambda below it'll keep self alive which will lead to + # 'ui-not-getting-cleaned-up' warnings and memory leaks. + auxiliary_style = self._auxiliary_style + controller = self.controller + request = self._request + last_response = self._last_response + has_had_response = self._has_had_response + uiopenstateid = ( + None if self._uiopenstate is None else self._uiopenstate.stateid + ) + suppress_win_extra_type_warning = self._suppress_win_extra_type_warning + + return bui.BasicMainWindowState( + create_call=( + lambda transition, origin_widget: controller.restore( + cls( + controller=controller, + request=request, + transition=transition, + origin_widget=origin_widget, + auxiliary_style=auxiliary_style, + uiopenstateid=uiopenstateid, + suppress_win_extra_type_warning=( + suppress_win_extra_type_warning + ), + restored=True, + has_had_response=has_had_response, + ), + last_response=last_response, + has_had_response=has_had_response, + ) + ), + uiopenstate=self._uiopenstate, + ) + + @override + def main_window_do_save_shared_state(self, state: dict) -> None: + # Give our controller a stab at this. + self.controller.save_window_shared_state(self, state) + + @override + def main_window_do_restore_shared_state(self, state: dict) -> None: + # Give our controller a stab at this. + self.controller.restore_window_shared_state(self, state) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True + + @override + def get_main_window_shared_state_id(self) -> str | None: + base_id = ( + self._request_state_id + if self._last_response_shared_state_id is None + else self._last_response_shared_state_id + ) + + # Each controller has its own unique domain, so include that in + # the id. + ctp = type(self.controller) + out = f'{ctp.__module__}.{ctp.__qualname__}:{base_id}' + return out diff --git a/dist/ba_data/python/bauiv1lib/docui/v1prep/__init__.py b/dist/ba_data/python/bauiv1lib/docui/v1prep/__init__.py new file mode 100644 index 0000000..5cf353a --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/v1prep/__init__.py @@ -0,0 +1,40 @@ +# Released under the MIT License. See LICENSE for details. +"""Functionality related to prepping a v1 doc-ui. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from bauiv1lib.docui.v1prep._types import ( + DecorationPrep, + ButtonPrep, + RowPrep, + PagePrep, +) +from bauiv1lib.docui.v1prep._calls import prep_page +from bauiv1lib.docui.v1prep._calls2 import ( + prep_text, + prep_decorations, + prep_image, + prep_row_debug, + prep_row_debug_button, + prep_button_debug, + prep_display_item, +) + +__all__ = [ + 'DecorationPrep', + 'ButtonPrep', + 'RowPrep', + 'PagePrep', + 'prep_page', + 'prep_text', + 'prep_decorations', + 'prep_image', + 'prep_row_debug', + 'prep_row_debug_button', + 'prep_button_debug', + 'prep_display_item', +] diff --git a/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls.py b/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls.py new file mode 100644 index 0000000..9cb30a8 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls.py @@ -0,0 +1,778 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Prep functionality for our UI. + +We do all layout math and bake out partial ui calls in a background +thread so there's as little work to do in the ui thread as possible. +""" + +from __future__ import annotations + +import copy +from functools import partial +from typing import TYPE_CHECKING, assert_never + +from efro.util import strict_partial +import bacommon.docui.v1 as dui1 +import bauiv1 as bui + +from bauiv1lib.docui.v1prep._types import PagePrep, RowPrep, ButtonPrep + +if TYPE_CHECKING: + from typing import Callable + + from bauiv1lib.docui import DocUIWindow + + +def prep_page( + page: dui1.Page, + *, + uiscale: bui.UIScale, + scroll_width: float, + scroll_height: float, + idprefix: str, + immediate: bool = False, +) -> PagePrep: + """Prep a page.""" + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + + import bauiv1lib.docui.v1prep._calls2 as prepcalls2 + + # Create a filtered list of rows we know how to display. + page_rows_filtered: list[dui1.ButtonRow] = [] + for pagerow in page.rows: + if isinstance(pagerow, dui1.ButtonRow): + if not pagerow.buttons: + pagerow = copy.deepcopy(pagerow) + pagerow.buttons.append( + dui1.Button( + label=bui.Lstr( + translate=( + 'serverResponses', + 'There is nothing here.', + ) + ).as_json(), + label_color=(1, 1, 1, 0.3), + label_is_lstr=True, + size=(220, 100), + label_scale=0.6, + texture='buttonSquareWide', + padding_top=-8, + padding_bottom=-10, + color=(0.2, 0.2, 0.2, 0.15), + action=dui1.Local(default_sound=False), + ) + ) + page_rows_filtered.append(pagerow) + if len(page_rows_filtered) != len(page.rows): + bui.uilog.error('Got unknown row type(s) in doc-ui; ignoring.') + + # Ok; we've got some buttons. Build our full UI. + row_title_height_with_subtitle = 30.0 + row_title_height_no_subtitle = 38.0 + row_subtitle_height = 30.0 + + # Buffers for *everything*. Set bases here that look decent and + # allow page to offset them. + top_buffer = 20.0 + page.padding_top + bot_buffer = 20.0 + page.padding_bottom + left_buffer = 10.0 + page.padding_left + # Nudge a bit due to scrollbar. + right_buffer = 20.0 + page.padding_right + + # Extra buffers for title/headers stuff (not in h-scroll). + header_inset_left = 45.0 + header_inset_right = 30.0 + + default_button_width = 150.0 + default_button_height = 100.0 + + if uiscale is bui.UIScale.SMALL: + top_bar_overlap = 70 + bot_bar_overlap = 70 + top_buffer += top_bar_overlap + bot_buffer += bot_bar_overlap + else: + top_bar_overlap = 0 + bot_bar_overlap = 0 + + # Should look into why this is necessary. + fudge = 15.0 + hscrollinset = 15.0 + + rootcall: Callable[..., bui.Widget] | None = None + rows: list[RowPrep] = [] + width: float = scroll_width + fudge + height: float = ( + top_buffer + + bot_buffer + + page.row_spacing * max(0, (len(page_rows_filtered) - 1)) + ) + simple_culling_v: float = page.simple_culling_v + center_vertically: bool = page.center_vertically + title: str = page.title + title_is_lstr: bool = page.title_is_lstr + + # Called with root container after construction completes. + root_post_calls: list[Callable[[bui.Widget], None]] = [] + + nextbuttonid = 0 + + have_start_button = False + have_selected_button = False + + # Precalc basic info like dimensions for all rows. + for row in page_rows_filtered: + + # assert row.buttons + this_row_width = ( + left_buffer + + right_buffer + + row.padding_left + + row.padding_right + + row.button_spacing * (len(row.buttons) - 1) + ) + button_row_height = 30.0 + for button in row.buttons: + if button.size is None: + bwidth = default_button_width + bheight = default_button_height + else: + bwidth = button.size[0] + bheight = button.size[1] + bscale = button.scale + bwidthfull = bwidth * bscale + bheightfull = bheight * bscale + # Include button padding when calcing full needed height. + button_row_height = max( + button_row_height, + bheightfull + + (button.padding_top + button.padding_bottom) * button.scale, + ) + this_row_width += ( + bwidthfull + + (button.padding_left + button.padding_right) * button.scale + ) + # Note: this includes everything in the *scrollable* part of + # the row. + this_row_height = ( + row.padding_top + row.padding_bottom + button_row_height + ) + rows.append( + RowPrep( + width=this_row_width, + height=this_row_height, + titlecalls=[], + hscrollcall=None, + hscrolleditcall=None, + hsubcall=None, + buttons=[], + simple_culling_h=row.simple_culling_h, + decorations=[], + ) + ) + assert this_row_height > 0.0 + assert this_row_width > 0.0 + + # Add height that is *not* part of the h-scrollable area. + height += row.header_height * row.header_scale + if row.title is not None: + height += ( + row_title_height_no_subtitle + if row.subtitle is None + else row_title_height_with_subtitle + ) + if row.subtitle is not None: + height += row_subtitle_height + height += this_row_height + height += row.spacing_top + row.spacing_bottom + + # Ok; we've got all row dimensions. Now prep calls to make the + # subcontainers to fit everything and fill out all rows. + rootcall = partial( + bui.containerwidget, + size=(width, height), + claims_left_right=True, + background=False, + ) + y = height - top_buffer + + for i, (row, rowprep) in enumerate( + zip(page_rows_filtered, rows, strict=True) + ): + tdelaybase = 0.06 * (i + 1) + + y -= row.spacing_top + + if i != 0: + y -= page.row_spacing + + # Header decorations. + header_height_full = row.header_height * row.header_scale + y -= header_height_full + hdecs_l = ( + [] + if row.header_decorations_left is None + else row.header_decorations_left + ) + prepcalls2.prep_decorations( + hdecs_l, + left_buffer + header_inset_left, + y + header_height_full * 0.5, + row.header_scale, + tdelay=None if immediate else (tdelaybase + 0.05), + highlight=False, + out_decoration_preps=rowprep.decorations, + ) + hdecs_c = ( + [] + if row.header_decorations_center is None + else row.header_decorations_center + ) + prepcalls2.prep_decorations( + hdecs_c, + width * 0.5, + y + header_height_full * 0.5, + row.header_scale, + tdelay=None if immediate else (tdelaybase + 0.05), + highlight=False, + out_decoration_preps=rowprep.decorations, + ) + hdecs_r = ( + [] + if row.header_decorations_right is None + else row.header_decorations_right + ) + prepcalls2.prep_decorations( + hdecs_r, + width - right_buffer - header_inset_right, + y + header_height_full * 0.5, + row.header_scale, + tdelay=None if immediate else (tdelaybase + 0.05), + highlight=False, + out_decoration_preps=rowprep.decorations, + ) + + if row.title is not None: + rowprep.titlecalls.append( + partial( + bui.textwidget, + position=( + ( + ((width - left_buffer - right_buffer) * 0.5) + + 7.0 # Fudge factor to match hscroll + if row.center_title + else (left_buffer + header_inset_left) + ), + y - row_subtitle_height * 0.5, + ), + size=(0, 0), + text=row.title, + color=( + (0.85, 0.95, 0.89, 1.0) + if row.title_color is None + else row.title_color + ), + flatness=row.title_flatness, + shadow=row.title_shadow, + scale=1.0, + maxwidth=( + (width - left_buffer - right_buffer) + if row.center_title + else ( + width + - left_buffer + - right_buffer + - header_inset_left + - header_inset_right + ) + ), + h_align='center' if row.center_title else 'left', + v_align='center', + literal=not row.title_is_lstr, + transition_delay=( + None if immediate else (tdelaybase + 0.1) + ), + ) + ) + y -= ( + row_title_height_no_subtitle + if row.subtitle is None + else row_title_height_with_subtitle + ) + if row.subtitle is not None: + rowprep.titlecalls.append( + partial( + bui.textwidget, + position=( + ( + ((width - left_buffer - right_buffer) * 0.5) + + 7.0 # Fudge factor to match hscroll + if row.center_title + else (left_buffer + header_inset_left) + ), + y - row_subtitle_height * 0.5, + ), + size=(0, 0), + text=row.subtitle, + color=( + (0.6, 0.74, 0.6) + if row.subtitle_color is None + else row.subtitle_color + ), + flatness=row.subtitle_flatness, + shadow=row.subtitle_shadow, + scale=0.7, + maxwidth=( + (width - left_buffer - right_buffer) + if row.center_title + else ( + width + - left_buffer + - right_buffer + - header_inset_left + - header_inset_right + ) + ), + h_align='center' if row.center_title else 'left', + v_align='center', + literal=not row.subtitle_is_lstr, + transition_delay=( + None if immediate else (tdelaybase + 0.2) + ), + ) + ) + y -= row_subtitle_height + + y -= rowprep.height # includes padding-top/bottom + + if row.debug: + rowheightfull = ( + rowprep.height + row.header_height * row.header_scale + ) + if row.title is not None: + rowheightfull += ( + row_title_height_no_subtitle + if row.subtitle is None + else row_title_height_with_subtitle + ) + if row.subtitle is not None: + rowheightfull += row_subtitle_height + prepcalls2.prep_row_debug( + ( + width - left_buffer - right_buffer, + rowheightfull, + ), + (left_buffer, y), + None if immediate else tdelaybase, + rowprep.decorations, + ) + + rowprep.hscrollcall = partial( + bui.hscrollwidget, + size=(width - hscrollinset, rowprep.height), + position=(hscrollinset, y), + claims_left_right=True, + highlight=False, + border_opacity=0.0, + center_small_content=row.center_content, + simple_culling_h=row.simple_culling_h, + ) + rowprep.hsubcall = partial( + bui.containerwidget, + size=( + # Ideally we could just always use row-width, but + # currently that gets us right-aligned stuff when + # center-small-content is off. + ( + rowprep.width + if row.center_content + else max(width - hscrollinset - fudge, rowprep.width) + ), + rowprep.height, + ), + background=False, + ) + x = left_buffer + row.padding_left + # Calc height of buttons themselves (includes button padding but + # not row padding). + button_row_height = ( + rowprep.height - row.padding_top - row.padding_bottom + ) + bcount = len(row.buttons) + + # Clamp or max delay if we've got lots of buttons. + bdelaymax = min(0.5, 0.03 * bcount) + for j, button in enumerate(row.buttons): + # Calc amt 1 -> 0 across the row. + tdelayamt = 1.0 - (j / max(1, bcount - 1)) + # Rightmost buttons slide in first. + tdelay = tdelaybase + tdelayamt * bdelaymax + + xorig = x + x += button.padding_left * button.scale + bscale = button.scale + if button.size is None: + bwidth = default_button_width + bheight = default_button_height + else: + bwidth = button.size[0] + bheight = button.size[1] + bwidthfull = bscale * bwidth + bheightfull = bscale * bheight + # Vertically center the button plus its padding. + to_button_plus_padding_bottom = ( + button_row_height + - ( + bheightfull + + (button.padding_top + button.padding_bottom) + * button.scale + ) + ) * 0.5 + # Move up past bottom padding to get button bottom. + to_button_bottom = ( + to_button_plus_padding_bottom + + button.padding_bottom * button.scale + ) + + center_x = x + bwidthfull * 0.5 + center_y = row.padding_bottom + to_button_bottom + bheightfull * 0.5 + + bstyle: str + if button.style is dui1.ButtonStyle.SQUARE: + bstyle = 'square' + elif button.style is dui1.ButtonStyle.TAB: + bstyle = 'tab' + elif button.style is dui1.ButtonStyle.SMALL: + bstyle = 'small' + elif button.style is dui1.ButtonStyle.MEDIUM: + bstyle = 'medium' + elif button.style is dui1.ButtonStyle.LARGE: + bstyle = 'large' + elif button.style is dui1.ButtonStyle.LARGER: + bstyle = 'larger' + elif button.style is dui1.ButtonStyle.BACK: + bstyle = 'back' + elif button.style is dui1.ButtonStyle.BACK_SMALL: + bstyle = 'backSmall' + elif button.style is dui1.ButtonStyle.SQUARE_WIDE: + bstyle = 'squareWide' + else: + assert_never(button.style) + + widgetid: str + if button.widget_id is None: + widgetid = f'{idprefix}|button{nextbuttonid}' + nextbuttonid += 1 + else: + widgetid = f'{idprefix}|{button.widget_id}' + + if button.default: + if have_start_button: + bui.uilog.warning( + 'Multiple buttons flagged as default.' + ' There can be only one per page.' + ) + else: + have_start_button = True + root_post_calls.append(partial(_set_start_button, widgetid)) + if button.selected: + if have_selected_button: + bui.uilog.warning( + 'Multiple buttons flagged as selected.' + ' There can be only one per page.' + ) + else: + have_selected_button = True + root_post_calls.append( + partial(_set_selected_button, widgetid) + ) + + show_buffer_left = button.padding_left * bscale + show_buffer_right = button.padding_right * bscale + + # Calc the total height of what we're trying to keep on + # screen, and then nudge that towards the total visible + # height of the scroll area. + total_show_width = ( + bwidth + button.padding_left + button.padding_right + ) * bscale + + # How much to push show-height towards full available space. + # 1.0 should lead to always perfect centering (but that + # might feel too aggressive). + amt = 0.6 + buffer_extra = max( + 0.0, (scroll_width - total_show_width) * 0.5 * amt + ) + show_buffer_left += buffer_extra + show_buffer_right += buffer_extra + + buttonprep = ButtonPrep( + buttoncall=partial( + bui.buttonwidget, + id=widgetid, + position=(x, row.padding_bottom + to_button_bottom), + size=(bwidth, bheight), + scale=bscale, + color=(None if button.color is None else button.color[:3]), + textcolor=button.label_color, + text_flatness=(button.label_flatness), + text_scale=button.label_scale, + button_type=bstyle, + opacity=(1.0 if button.color is None else button.color[3]), + label='' if button.label is None else button.label, + text_literal=not button.label_is_lstr, + autoselect=True, + enable_sound=False, + transition_delay=None if immediate else tdelay, + icon_color=button.icon_color, + iconscale=button.icon_scale, + better_bg_fit=True, + ), + buttoneditcall=partial( + bui.widget, + # TODO: Calc left/right vals properly based on + # our size and padding. + show_buffer_left=show_buffer_left, + show_buffer_right=show_buffer_right, + depth_range=button.depth_range, + # We explicitly assign all neighbor selection; + # anything left over should go to toolbars. + auto_select_toolbars_only=True, + ), + decorations=[], + textures={}, + widgetid=widgetid, + action=button.action, + ) + if button.texture is not None: + buttonprep.textures['texture'] = button.texture + + if button.icon is not None: + buttonprep.textures['icon'] = button.icon + + # With row-debug on, visualize the area we try to scroll to + # show when each button is selected. Note that we're clamped + # by the h-scroll here so we have to draw a separate box for + # the row title/subtitle. + if row.debug: + prepcalls2.prep_row_debug_button( + ( + bwidthfull + + (button.padding_left + button.padding_right) + * button.scale, + rowprep.height, + ), + (xorig, 0.0), + None if immediate else tdelay, + buttonprep.decorations, + ) + + if button.debug: + prepcalls2.prep_button_debug( + (bwidthfull, bheightfull), + (center_x, center_y), + None if immediate else tdelay, + buttonprep.decorations, + ) + decorations = ( + [] if button.decorations is None else button.decorations + ) + prepcalls2.prep_decorations( + decorations, + center_x, + center_y, + bscale, + None if immediate else tdelay, + highlight=True, + out_decoration_preps=buttonprep.decorations, + ) + + rowprep.buttons.append(buttonprep) + + x += ( + bwidthfull + + (button.padding_right * button.scale) + + row.button_spacing + ) + + # Add an edit call for our new hscroll to give it proper + # show-buffers. + + # Incorporate top buffer so we scroll all the way up + # when selecting the top row (and stay clear of + # toolbars). + show_buffer_top = top_buffer + show_buffer_bottom = bot_buffer + + # Scroll so title/subtitle is in view when selecting. + # Note that we don't need to account for + # padding-top/bottom since the h-scroll that we're + # applying to encompasses both. + show_buffer_top += row.header_height * row.header_scale + if row.title is not None: + show_buffer_top += ( + row_title_height_no_subtitle + if row.subtitle is None + else row_title_height_with_subtitle + ) + if row.subtitle is not None: + show_buffer_top += row_subtitle_height + + # Calc the total height of what we're trying to keep on + # screen, and then nudge that towards the total visible + # height of the scroll area. + total_show_height = ( + rowprep.height + show_buffer_top + show_buffer_bottom + ) + # How much to push show-height towards full available space. + # 1.0 should lead to always perfect centering (but that + # might feel too aggressive). + amt = 0.5 + buffer_extra = max(0.0, (scroll_height - total_show_height) * 0.5 * amt) + + show_buffer_top += buffer_extra + show_buffer_bottom += buffer_extra + + rowprep.hscrolleditcall = partial( + bui.widget, + show_buffer_top=show_buffer_top, + show_buffer_bottom=show_buffer_bottom, + ) + y -= row.spacing_bottom + + return PagePrep( + rootcall=rootcall, + rows=rows, + width=width, + height=height, + simple_culling_v=simple_culling_v, + center_vertically=center_vertically, + title=title, + title_is_lstr=title_is_lstr, + root_post_calls=root_post_calls, + ) + + +def doc_ui_v1_instantiate_page_prep( + pageprep: PagePrep, + *, + rootwidget: bui.Widget, + scrollwidget: bui.Widget, + backbutton: bui.Widget, + windowbackbutton: bui.Widget | None, + window: DocUIWindow, +) -> bui.Widget: + """Create a UI using prepped data.""" + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + outrows: list[tuple[bui.Widget, list[bui.Widget]]] = [] + + # Now go through and run our prepped ui calls to build our + # widgets, plugging in appropriate parent widgets args and + # whatnot as we go. + assert pageprep.rootcall is not None + subcontainer = pageprep.rootcall(parent=scrollwidget) + for rowprep in pageprep.rows: + for uicall in rowprep.titlecalls: + uicall(parent=subcontainer) + assert rowprep.hscrollcall is not None + hscroll = rowprep.hscrollcall(parent=subcontainer) + for decoration in rowprep.decorations: + kwds: dict = {'parent': subcontainer} + for texarg, texname in decoration.textures.items(): + kwds[texarg] = bui.gettexture(texname) + for mesharg, meshname in decoration.meshes.items(): + kwds[mesharg] = bui.getmesh(meshname) + decoration.call(**kwds) + outrow: tuple[bui.Widget, list[bui.Widget]] = (hscroll, []) + assert rowprep.hsubcall is not None + hsub = rowprep.hsubcall(parent=hscroll) + for i, buttonprep in enumerate(rowprep.buttons): + kwds = { + 'parent': hsub, + 'on_activate_call': strict_partial( + window.controller.run_action, + window, + buttonprep.widgetid, + buttonprep.action, + ), + } + for texarg, texname in buttonprep.textures.items(): + kwds[texarg] = bui.gettexture(texname) + btn = buttonprep.buttoncall(**kwds) + assert buttonprep.buttoneditcall is not None + buttonprep.buttoneditcall(edit=btn) + for decoration in buttonprep.decorations: + kwds = {'parent': hsub} + if decoration.highlight: + kwds['draw_controller'] = btn + for texarg, texname in decoration.textures.items(): + kwds[texarg] = bui.gettexture(texname) + for mesharg, meshname in decoration.meshes.items(): + kwds[mesharg] = bui.getmesh(meshname) + decoration.call(**kwds) + + # Make sure row is scrolled so leftmost button is + # visible (though it kinda seems like this should happen + # by default). + if i == 0: + bui.containerwidget(edit=hsub, visible_child=btn) + outrow[1].append(btn) + + outrows.append(outrow) + assert rowprep.hscrolleditcall is not None + rowprep.hscrolleditcall(edit=hscroll) + + for root_post_call in pageprep.root_post_calls: + root_post_call(rootwidget) + + # Ok; we've got all widgets. Now wire up directional nav between + # rows/buttons. + + # Up press on any top-row button should select window back button + # (if there is one). + if outrows and windowbackbutton is not None: + _scroll, buttons = outrows[0] + for button in buttons: + bui.widget(edit=button, up_widget=windowbackbutton) + for _scroll, buttons in outrows: + # Left press on first button in any row should select back + # button (either system one or window one). + if buttons: + bui.widget(edit=buttons[0], left_widget=backbutton) + # Left/right presses should select neighbor button in + # row (when there is one). + for i in range(0, len(buttons) - 1): + leftbutton = buttons[i] + rightbutton = buttons[i + 1] + bui.widget(edit=leftbutton, right_widget=rightbutton) + bui.widget(edit=rightbutton, left_widget=leftbutton) + # Down/up presses should select next/prev row (when there is + # one). + for i in range(0, len(outrows) - 1): + topscroll, topbuttons = outrows[i] + botscroll, botbuttons = outrows[i + 1] + for topbutton in topbuttons: + bui.widget(edit=topbutton, down_widget=botscroll) + for botbutton in botbuttons: + bui.widget(edit=botbutton, up_widget=topscroll) + + return subcontainer + + +def _set_start_button(buttonid: str, root: bui.Widget) -> None: + widget = bui.widget_by_id(buttonid) + if widget: + bui.containerwidget(edit=root, start_button=widget) + + +def _set_selected_button(buttonid: str, root: bui.Widget) -> None: + del root # Unused. + widget = bui.widget_by_id(buttonid) + if widget: + widget.global_select() diff --git a/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls2.py b/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls2.py new file mode 100644 index 0000000..192255c --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/v1prep/_calls2.py @@ -0,0 +1,604 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Prep functionality for our UI. + +We do all layout math and bake out partial ui calls in a background +thread so there's as little work to do in the ui thread as possible. +""" + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, assert_never + +from efro.util import pairs_from_flat +import bacommon.displayitem as ditm +import bacommon.docui.v1 as dui1 +import bauiv1 as bui + +from bauiv1lib.docui.v1prep._types import DecorationPrep + +if TYPE_CHECKING: + from typing import Callable + + from bauiv1lib.docui import DocUIWindow + + +def prep_decorations( + decorations: list[dui1.Decoration], + center_x: float, + center_y: float, + scale: float, + tdelay: float | None, + *, + highlight: bool, + out_decoration_preps: list[DecorationPrep], +) -> None: + """Prep appropriate decoration types for a list of decorations.""" + for decoration in decorations: + dectypeid = decoration.get_type_id() + if dectypeid is dui1.DecorationTypeID.UNKNOWN: + if bui.do_once(): + bui.uilog.exception( + 'DocUI receieved unknown decoration;' + ' this is likely a server error.' + ) + elif dectypeid is dui1.DecorationTypeID.TEXT: + assert isinstance(decoration, dui1.Text) + prep_text( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + highlight=highlight, + ) + + elif dectypeid is dui1.DecorationTypeID.IMAGE: + assert isinstance(decoration, dui1.Image) + prep_image( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + highlight=highlight, + ) + elif dectypeid is dui1.DecorationTypeID.DISPLAY_ITEM: + assert isinstance(decoration, dui1.DisplayItem) + prep_display_item( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + highlight=highlight, + ) + else: + assert_never(dectypeid) + + +def prep_text( + text: dui1.Text, + bcenter: tuple[float, float], + bscale: float, + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], + *, + highlight: bool, +) -> None: + """Prep decorations for text.""" + # pylint: disable=too-many-branches + xoffs = bcenter[0] + text.position[0] * bscale + yoffs = bcenter[1] + text.position[1] * bscale + + if text.h_align is dui1.HAlign.LEFT: + h_align = 'left' + elif text.h_align is dui1.HAlign.CENTER: + h_align = 'center' + elif text.h_align is dui1.HAlign.RIGHT: + h_align = 'right' + else: + assert_never(text.h_align) + + if text.v_align is dui1.VAlign.TOP: + v_align = 'top' + elif text.v_align is dui1.VAlign.CENTER: + v_align = 'center' + elif text.v_align is dui1.VAlign.BOTTOM: + v_align = 'bottom' + else: + assert_never(text.v_align) + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.textwidget, + position=(xoffs, yoffs), + scale=text.scale * bscale, + maxwidth=text.size[0] * bscale, + max_height=text.size[1] * bscale, + flatness=text.flatness, + shadow=text.shadow, + h_align=h_align, + v_align=v_align, + size=(0, 0), + color=text.color, + text=text.text, + literal=not text.is_lstr, + transition_delay=tdelay, + depth_range=text.depth_range, + ), + textures={}, + meshes={}, + highlight=highlight and text.highlight, + ) + ) + # Draw square around max width/height in debug mode. + if text.debug: + mwfull = bscale * text.size[0] + mhfull = bscale * text.size[1] + + if text.h_align is dui1.HAlign.LEFT: + mwxoffs = xoffs + elif text.h_align is dui1.HAlign.CENTER: + mwxoffs = xoffs - mwfull * 0.5 + elif text.h_align is dui1.HAlign.RIGHT: + mwxoffs = xoffs - mwfull + else: + assert_never(text.h_align) + + if text.v_align is dui1.VAlign.TOP: + mwyoffs = yoffs - mhfull + elif text.v_align is dui1.VAlign.CENTER: + mwyoffs = yoffs - mhfull * 0.5 + elif text.v_align is dui1.VAlign.BOTTOM: + mwyoffs = yoffs + else: + assert_never(text.v_align) + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=(mwxoffs, mwyoffs), + size=(mwfull, mhfull), + color=(1, 0, 0), + opacity=0.2, + transition_delay=tdelay, + ), + textures={'texture': 'white'}, + meshes={}, + highlight=True, + ) + ) + + +def prep_image( + image: dui1.Image, + bcenter: tuple[float, float], + bscale: float, + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], + *, + highlight: bool, +) -> None: + """Prep decorations for an image.""" + xoffs = bcenter[0] + image.position[0] * bscale + yoffs = bcenter[1] + image.position[1] * bscale + + widthfull = bscale * image.size[0] + heightfull = bscale * image.size[1] + + if image.h_align is dui1.HAlign.LEFT: + xoffsfin = xoffs + elif image.h_align is dui1.HAlign.CENTER: + xoffsfin = xoffs - widthfull * 0.5 + elif image.h_align is dui1.HAlign.RIGHT: + xoffsfin = xoffs - widthfull + else: + assert_never(image.h_align) + + if image.v_align is dui1.VAlign.TOP: + yoffsfin = yoffs - heightfull + elif image.v_align is dui1.VAlign.CENTER: + yoffsfin = yoffs - heightfull * 0.5 + elif image.v_align is dui1.VAlign.BOTTOM: + yoffsfin = yoffs + else: + assert_never(image.v_align) + + textures: dict[str, str] = {'texture': image.texture} + if image.tint_texture is not None: + textures['tint_texture'] = image.tint_texture + if image.mask_texture is not None: + textures['mask_texture'] = image.mask_texture + + meshes: dict[str, str] = {} + if image.mesh_opaque is not None: + meshes['mesh_opaque'] = image.mesh_opaque + if image.mesh_transparent is not None: + meshes['mesh_transparent'] = image.mesh_transparent + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=(xoffsfin, yoffsfin), + size=(widthfull, heightfull), + color=None if image.color is None else image.color[:3], + opacity=1.0 if image.color is None else image.color[3], + tint_color=image.tint_color, + tint2_color=image.tint2_color, + transition_delay=tdelay, + depth_range=image.depth_range, + ), + textures=textures, + meshes=meshes, + highlight=highlight and image.highlight, + ) + ) + + +def prep_row_debug( + size: tuple[float, float], + pos: tuple[float, float], + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], +) -> None: + """Prep debug decorations for a row.""" + + textures: dict[str, str] = {'texture': 'white'} + + # Shrink the square we draw a tiny bit so rows butted up to + # eachother can be seen. + border_shrink = 1.0 + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=(pos[0], pos[1] + border_shrink), + size=(size[0], size[1] - 2.0 * border_shrink), + color=(0, 0, 1.0), + opacity=0.1, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def prep_row_debug_button( + bsize: tuple[float, float], + bcorner: tuple[float, float], + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], +) -> None: + """Prep debug decorations for a button.""" + xoffs = bcorner[0] + yoffs = bcorner[1] + + textures: dict[str, str] = {'texture': 'white'} + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=(xoffs, yoffs), + size=bsize, + color=(0.0, 0.0, 1), + opacity=0.15, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def prep_button_debug( + bsize: tuple[float, float], + bcenter: tuple[float, float], + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], +) -> None: + """Prep debug decorations for a button.""" + textures: dict[str, str] = {'texture': 'white'} + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=( + bcenter[0] - bsize[0] * 0.5, + bcenter[1] - bsize[1] * 0.5, + ), + size=bsize, + color=(0, 1, 0), + opacity=0.1, + transition_delay=tdelay, + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def prep_display_item( + display_item: dui1.DisplayItem, + parent_center: tuple[float, float], + parent_scale: float, + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], + *, + highlight: bool, +) -> None: + """Prep decorations for a display-item.""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + + # Calc center and size of our bounds based on parent. + our_center = ( + parent_center[0] + display_item.position[0] * parent_scale, + parent_center[1] + display_item.position[1] * parent_scale, + ) + bounds_size = ( + parent_scale * display_item.size[0], + parent_scale * display_item.size[1], + ) + + wrapper = display_item.wrapper + item = wrapper.item + itemtype = item.get_type_id() + + # Draw our bounds if debug mode is enabled (or we're a test-item). + if display_item.debug or itemtype is ditm.ItemTypeID.TEST: + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + color=(1, 1, 0), + opacity=0.1, + position=( + our_center[0] - bounds_size[0] * 0.5, + our_center[1] - bounds_size[1] * 0.5, + ), + size=bounds_size, + transition_delay=tdelay, + ), + textures={'texture': 'white'}, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) + + # Calc our width and height based on our aspect ratio so we fit in + # the provided bounds. + if display_item.style is dui1.DisplayItemStyle.FULL: + aspect_ratio = 0.75 # Bit less tall than wide (graphic centric). + compact = False + icon = False + elif display_item.style is dui1.DisplayItemStyle.COMPACT: + aspect_ratio = 0.5 # Significantly wider (text centric) + compact = True + icon = False + elif display_item.style is dui1.DisplayItemStyle.ICON: + aspect_ratio = 1.0 # Square + compact = False + icon = True + else: + # Make sure we cover all possibilities. + assert_never(display_item.style) + + if bounds_size[0] * aspect_ratio > bounds_size[1]: + height = bounds_size[1] + width = height / aspect_ratio + else: + width = bounds_size[0] + height = width * aspect_ratio + + # Show our constrained bounds in debug mode. + if display_item.debug or itemtype is ditm.ItemTypeID.TEST: + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + color=(1, 0.5, 0), + opacity=0.2, + position=( + our_center[0] - width * 0.5, + our_center[1] - height * 0.5, + ), + size=(width, height), + transition_delay=tdelay, + ), + textures={'texture': 'white'}, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) + + img: str | None = None + img_x_offs = 0.0 + img_y_offs = 0.0 + imgsize = width * (0.5 if compact else 1.0 if icon else 0.33) + + show_text = True + text_mult = 0.006 + text: str | None = None # Uses default if None + text_x_offs = 0.0 + text_y_offs = 0.0 + text_align = 'center' + text_max_width: float | None = width * 0.9 + + if itemtype is ditm.ItemTypeID.CHEST: + from baclassic import ( + CHEST_APPEARANCE_DISPLAY_INFOS, + CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, + ) + import bacommon.classic + + assert isinstance(item, bacommon.classic.ClassicChestDisplayItem) + + img = None + show_text = False + c_info = CHEST_APPEARANCE_DISPLAY_INFOS.get( + item.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT + ) + c_size = width * (0.66 if compact else 1.05 if icon else 0.83) + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=( + our_center[0] - c_size * 0.5, + our_center[1] - c_size * 0.5, + ), + size=(c_size, c_size), + transition_delay=tdelay, + tint_color=c_info.tint, + tint2_color=c_info.tint2, + depth_range=display_item.depth_range, + ), + textures={ + 'texture': c_info.texclosed, + 'tint_texture': c_info.texclosedtint, + }, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) + elif itemtype is ditm.ItemTypeID.TEST: + assert isinstance(item, ditm.Test) + # Nothing to do here. This is just another way to enable debug + # drawing. + if icon or compact: + text_mult = 0.02 # Very large text. + + elif ( + itemtype is ditm.ItemTypeID.TOKENS + or itemtype is ditm.ItemTypeID.TICKETS + or itemtype is ditm.ItemTypeID.TICKETS_PURPLE + ): + if itemtype is ditm.ItemTypeID.TOKENS: + assert isinstance(item, ditm.Tokens) + img = 'coin' + if compact: + text = str(item.count) + elif itemtype is ditm.ItemTypeID.TICKETS: + assert isinstance(item, ditm.Tickets) + img = 'tickets' + if compact: + text = str(item.count) + elif itemtype is ditm.ItemTypeID.TICKETS_PURPLE: + assert isinstance(item, ditm.PurpleTickets) + img = 'ticketsPurple' + if compact: + text = str(item.count) + else: + assert_never(itemtype) + + if compact: + imgamt = 0.85 # How much of img dimensions we measure. + + assert text is not None + text_mult = 0.01 + strwidth = ( + width + * bui.get_string_width(text, suppress_warning=True) + * text_mult + ) + totwidth = strwidth + imgsize * imgamt + + maxwidth = width * 0.95 + if totwidth > maxwidth: + mult = maxwidth / totwidth + text_mult *= mult + strwidth *= mult + totwidth *= mult + imgsize *= mult + + text_max_width = None # We calc this fully ourself. + # Move to right and then left by half img width. + img_x_offs = totwidth * 0.5 - imgsize * imgamt * 0.5 + # Move to left and then right by half text width. + text_x_offs = totwidth * -0.5 + strwidth * 0.5 + elif icon: + img_y_offs = 0.0 + show_text = False + else: + img_y_offs = width * 0.11 + text_y_offs = width * -0.15 + elif itemtype is ditm.ItemTypeID.UNKNOWN: + assert isinstance(item, ditm.Unknown) + # Just do default text here. + if icon: + text_mult = 0.02 # Very large text. + else: + # Make sure we cover all possibilities. + assert_never(itemtype) + + if img is not None: + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.imagewidget, + position=( + our_center[0] - imgsize * 0.5 + img_x_offs, + our_center[1] - imgsize * 0.5 + img_y_offs, + ), + size=(imgsize, imgsize), + transition_delay=tdelay, + depth_range=display_item.depth_range, + ), + textures={'texture': img}, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) + if show_text: + if text is None: + subs = wrapper.description_subs + if subs is None: + subs = [] + text = bui.Lstr( + translate=('displayItemNames', wrapper.description), + subs=pairs_from_flat(subs), + ).as_json() + + out_decoration_preps.append( + DecorationPrep( + call=partial( + bui.textwidget, + position=( + our_center[0] + text_x_offs, + our_center[1] + text_y_offs, + ), + scale=width * text_mult, + maxwidth=text_max_width, + h_align=text_align, + v_align='center', + size=(0, 0), + color=( + (1, 1, 1) + if display_item.text_color is None + else display_item.text_color + ), + text=text, + flatness=1.0, + shadow=1.0, + literal=False, + transition_delay=tdelay, + depth_range=display_item.depth_range, + ), + textures={}, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) diff --git a/dist/ba_data/python/bauiv1lib/docui/v1prep/_types.py b/dist/ba_data/python/bauiv1lib/docui/v1prep/_types.py new file mode 100644 index 0000000..62d8f71 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/v1prep/_types.py @@ -0,0 +1,73 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Types used in prepping v1 doc-ui. + +Prepping involves doing as much math and layout work as possible in a +pre-pass (generally run in a background thread) so that the actual calls +made to instantiate the ui are as fast and minimal as possible. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + + import bacommon.docui.v1 + import bauiv1 + + from bauiv1lib.docui._window import DocUIWindow + + +@dataclass +class DecorationPrep: + """Prep for a decoration in a v1 doc-ui""" + + call: Callable[..., bauiv1.Widget] + textures: dict[str, str] + meshes: dict[str, str] + highlight: bool + + +@dataclass +class ButtonPrep: + """Prep for a button in a v1 doc-ui""" + + buttoncall: Callable[..., bauiv1.Widget] + buttoneditcall: Callable | None + decorations: list[DecorationPrep] + textures: dict[str, str] + widgetid: str + action: bacommon.docui.v1.Action | None + + +@dataclass +class RowPrep: + """Prep for a row in a v1 doc-ui""" + + width: float + height: float + titlecalls: list[Callable[..., bauiv1.Widget]] + hscrollcall: Callable[..., bauiv1.Widget] | None + hscrolleditcall: Callable | None + hsubcall: Callable[..., bauiv1.Widget] | None + buttons: list[ButtonPrep] + simple_culling_h: float + decorations: list[DecorationPrep] + + +@dataclass +class PagePrep: + """Prep for a page in a v1 doc-ui""" + + rootcall: Callable[..., bauiv1.Widget] | None + rows: list[RowPrep] + width: float + height: float + simple_culling_v: float + center_vertically: bool + title: str + title_is_lstr: bool + root_post_calls: list[Callable[[bauiv1.Widget], None]] diff --git a/dist/ba_data/python/bauiv1lib/docuitest.py b/dist/ba_data/python/bauiv1lib/docuitest.py new file mode 100644 index 0000000..6e3430c --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docuitest.py @@ -0,0 +1,1092 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Examples/tests for using DocUI to build UIs.""" + +# pylint: disable=too-many-lines +from __future__ import annotations + +import time +import copy +from typing import TYPE_CHECKING, override + +from efro.error import CleanError +import bauiv1 as bui + +from bauiv1lib.docui import DocUIWindow, DocUIController + +if TYPE_CHECKING: + from bacommon.docui import DocUIRequest, DocUIResponse + import bacommon.docui.v1 + + from bauiv1lib.docui import DocUILocalAction + + +def show_test_doc_ui_window() -> None: + """Bust out a doc-ui window.""" + import bacommon.docui.v1 as dui1 + + # Pop up an auxiliary window wherever we are in the nav stack. + bui.app.ui_v1.auxiliary_window_activate( + win_type=DocUIWindow, + win_create_call=bui.CallStrict( + TestDocUIController().create_window, dui1.Request('/') + ), + win_extra_type_id=TestDocUIController.get_window_extra_type_id(), + ) + + +class TestDocUIController(DocUIController): + """Provides various tests/demonstrations of docui functionality.""" + + @override + def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: + """Fulfill a request. + + Will be called in a background thread. + """ + # pylint: disable=too-many-return-statements + + import bacommon.docui.v1 as dui1 + + # We currently support v1 requests only. + if not isinstance(request, dui1.Request): + raise CleanError('Invalid request version.') + + # Handle some pages purely locally. + if request.path == '/': + return _test_page_root(request) + if request.path == '/test2': + return _test_page_2(request) + if request.path == '/slow': + return _test_page_long(request) + if request.path == '/timedactions': + return _test_page_timed_actions(request) + if request.path == '/displayitems': + return _test_page_display_items(request) + if request.path == '/emptypage': + return _test_page_empty(request) + if request.path == '/boundstests': + return _test_bounds(request) + + # Ship '/webtest/*' off to some webserver to handle. + if request.path.startswith('/webtest/'): + return self.fulfill_request_web( + request, 'https://www.ballistica.net/docuitest' + ) + + # Ship '/cloudmsgtest/*' through our cloud connection to handle. + if request.path.startswith('/cloudmsgtest/'): + return self.fulfill_request_cloud(request, 'test') + + raise CleanError('Invalid request path.') + + @override + def local_action(self, action: DocUILocalAction) -> None: + bui.screenmessage( + f'Would do {action.name!r} with args {action.args!r}.' + ) + + +def _test_page_long( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + """Testing a page that takes a bit of time to load.""" + import bacommon.docui.v1 as dui1 + + del request # Unused. + + # Simulate a slow connection or whatnot. + time.sleep(3.0) + + return dui1.Response( + page=dui1.Page( + title='Test', + center_vertically=True, + rows=[ + dui1.ButtonRow( + title='That took a while', + center_title=True, + center_content=True, + buttons=[ + dui1.Button( + 'Sure Did', + size=(120, 80), + action=dui1.Browse(dui1.Request('/')), + ), + ], + ), + ], + ) + ) + + +def _test_page_timed_actions( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + """Testing a page that takes a bit of time to load.""" + import bacommon.docui.v1 as dui1 + + val = request.args.get('val') + if not isinstance(val, int): + val = 5 + + return dui1.Response( + page=dui1.Page( + title='Test', + center_vertically=True, + rows=[ + dui1.ButtonRow( + title=f'Hello there {val}', + subtitle='Each change here is a new request/response.', + center_title=True, + center_content=True, + buttons=[ + dui1.Button( + 'Done', + size=(120, 80), + action=dui1.Local(close_window=True), + default=True, + ), + ], + ), + ], + ), + # Refresh this page with a countdown until we hit zero and then + # close the window. + timed_action=( + dui1.Replace(dui1.Request('/timedactions', args={'val': val - 1})) + if (val - 1) > 0 + else dui1.Local(close_window=True) + ), + timed_action_delay=1.0, + ) + + +def _test_page_effects() -> bacommon.docui.v1.Page: + """Testing effects after a page load.""" + import bacommon.docui.v1 as dui1 + + return dui1.Page( + title='Effects', + center_vertically=True, + rows=[ + dui1.ButtonRow( + title='Have some lovely effects', + center_title=True, + center_content=True, + buttons=[ + dui1.Button( + 'Nice!', + size=(120, 80), + action=dui1.Local(close_window=True), + ), + ], + ), + ], + ) + + +def _test_page_2( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + """More testing.""" + import bacommon.docui.v1 as dui1 + + del request # Unused. + + return dui1.Response( + page=dui1.Page( + title='Test 2', + rows=[ + dui1.ButtonRow( + title='More Tests', + buttons=[ + dui1.Button( + 'Browse', + size=(120, 80), + action=dui1.Browse(dui1.Request('/')), + ), + dui1.Button( + 'Replace', + size=(120, 80), + action=dui1.Replace(dui1.Request('/')), + ), + dui1.Button( + 'Close', + size=(120, 80), + action=dui1.Local(close_window=True), + selected=True, # Testing this + ), + ], + ), + ], + ) + ) + + +def _test_page_root( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + """Return test page.""" + + import bacommon.clienteffect as clfx + import bacommon.docui.v1 as dui1 + + # Show some specific debug bits if they ask us to. + debug = bool(request.args.get('debug', False)) + + response = dui1.Response( + page=dui1.Page( + title='Test Root', + rows=[ + dui1.ButtonRow( + debug=debug, + header_height=100, + header_decorations_left=[ + dui1.Text( + 'HeaderLeft', + position=(0, 10 + 20), + color=(1, 1, 1, 0.3), + size=(150, 30), + h_align=dui1.HAlign.LEFT, + debug=debug, + ), + ], + header_decorations_center=[ + dui1.Text( + 'Hello From DocUI!', + position=(0, 10 + 20), + size=(300, 30), + debug=debug, + ), + dui1.Text( + ( + 'Use this as reference for building' + ' UIs with DocUI.' + ' Its code lives at bauiv1lib.docuitest' + ), + scale=0.5, + position=(0, -18 + 20), + size=(600, 23), + debug=debug, + ), + dui1.Image( + 'nub', position=(0, -58 + 20), size=(60, 60) + ), + ], + header_decorations_right=[ + dui1.Text( + 'HeaderRight', + position=(0, 10 + 20), + color=(1, 1, 1, 0.3), + size=(150, 30), + h_align=dui1.HAlign.RIGHT, + debug=debug, + ), + ], + title='Some Tests', + buttons=[ + dui1.Button( + 'Browse', + size=(120, 80), + action=dui1.Browse(dui1.Request('/test2')), + ), + dui1.Button( + 'Replace', + size=(120, 80), + action=dui1.Replace(dui1.Request('/test2')), + ), + dui1.Button( + 'Close', + size=(120, 80), + action=dui1.Local(close_window=True), + ), + dui1.Button( + 'Invalid\nRequest', + size=(120, 80), + action=dui1.Browse(dui1.Request('/invalidrequest')), + ), + dui1.Button( + 'Immediate\nClientEffects', + size=(120, 80), + action=dui1.Local( + immediate_client_effects=[ + clfx.ScreenMessage( + 'Hello From Immediate Client Effects', + color=(0, 1, 0), + ), + clfx.PlaySound(clfx.Sound.CASH_REGISTER), + clfx.Delay(1.0), + clfx.ScreenMessage( + '{"r":"successText"}', + is_lstr=True, + color=(0, 1, 0), + ), + clfx.PlaySound(clfx.Sound.CASH_REGISTER), + ] + ), + ), + dui1.Button( + 'Response\nClientEffects', + size=(120, 80), + action=dui1.Browse( + dui1.Request('/', args={'test_effects': True}) + ), + ), + dui1.Button( + 'Immediate\nLocalAction', + size=(120, 80), + action=dui1.Local( + immediate_local_action='testaction', + immediate_local_action_args={'testparam': 123}, + ), + ), + dui1.Button( + 'Response\nLocalAction', + size=(120, 80), + action=dui1.Browse( + dui1.Request('/', args={'test_action': True}) + ), + ), + ], + ), + dui1.ButtonRow( + title='A Few More Tests', + buttons=[ + dui1.Button( + 'Hide\nDebug' if debug else 'Show\nDebug', + size=(120, 80), + action=dui1.Replace( + dui1.Request('/', args={'debug': not debug}) + ), + ), + dui1.Button( + 'Slow\nBrowse', + size=(120, 80), + action=dui1.Browse(dui1.Request('/slow')), + ), + dui1.Button( + 'Slow\nReplace', + size=(120, 80), + action=dui1.Replace(dui1.Request('/slow')), + ), + dui1.Button( + 'Timed\nActions', + size=(120, 80), + action=dui1.Browse(dui1.Request('/timedactions')), + ), + dui1.Button( + 'Web\nGET', + size=(120, 80), + action=dui1.Browse(dui1.Request('/webtest/get')), + ), + dui1.Button( + 'Web\nPOST', + size=(120, 80), + action=dui1.Browse( + dui1.Request( + '/webtest/post', + method=dui1.RequestMethod.POST, + ) + ), + ), + dui1.Button( + 'DisplayItems', + size=(120, 80), + action=dui1.Browse(dui1.Request('/displayitems')), + ), + dui1.Button( + 'Empty\nPage', + size=(120, 80), + action=dui1.Browse(dui1.Request('/emptypage')), + ), + ], + ), + dui1.ButtonRow( + title='Even More Tests', + buttons=[ + dui1.Button( + 'Cloud-Msg\nGET', + size=(120, 80), + action=dui1.Browse( + dui1.Request('/cloudmsgtest/get') + ), + ), + dui1.Button( + 'Cloud-Msg\nPOST', + size=(120, 80), + action=dui1.Browse( + dui1.Request( + '/cloudmsgtest/post', + method=dui1.RequestMethod.POST, + ) + ), + ), + dui1.Button( + 'Bounds\nTests', + size=(120, 80), + action=dui1.Browse(dui1.Request('/boundstests')), + ), + ], + ), + dui1.ButtonRow(title='Empty Row', buttons=[]), + dui1.ButtonRow( + title='Layout Tests', + debug=debug, + padding_left=5.0, + buttons=[ + dui1.Button( + label='Test', + size=(180, 200), + decorations=[ + dui1.Image( + 'powerupPunch', + position=(-70, 0), + size=(40, 40), + h_align=dui1.HAlign.LEFT, + ), + dui1.Image( + 'powerupSpeed', + position=(0, 75), + size=(35, 35), + v_align=dui1.VAlign.TOP, + ), + dui1.Text( + 'TL', + position=(-70, 75), + size=(50, 50), + h_align=dui1.HAlign.LEFT, + v_align=dui1.VAlign.TOP, + debug=debug, + ), + dui1.Text( + 'TR', + position=(70, 75), + size=(50, 50), + h_align=dui1.HAlign.RIGHT, + v_align=dui1.VAlign.TOP, + debug=debug, + ), + dui1.Text( + 'BL', + position=(-70, -75), + size=(50, 50), + h_align=dui1.HAlign.LEFT, + v_align=dui1.VAlign.BOTTOM, + debug=debug, + ), + dui1.Text( + 'BR', + position=(70, -75), + size=(50, 50), + h_align=dui1.HAlign.RIGHT, + v_align=dui1.VAlign.BOTTOM, + debug=debug, + ), + ], + ), + dui1.Button( + label='Test2', + size=(100, 100), + color=(1, 0, 0, 1), + label_color=(1, 1, 1, 1), + padding_right=4, + ), + # Should look like the first button but + # scaled down. + dui1.Button( + label='Test', + size=(180, 200), + scale=0.6, + padding_bottom=30, # Should nudge us up. + debug=debug, # Show bounds. + decorations=[ + dui1.Image( + 'powerupPunch', + position=(-70, 0), + size=(40, 40), + h_align=dui1.HAlign.LEFT, + ), + dui1.Image( + 'powerupSpeed', + position=(0, 75), + size=(35, 35), + v_align=dui1.VAlign.TOP, + ), + dui1.Text( + 'TL', + position=(-70, 75), + size=(50, 50), + h_align=dui1.HAlign.LEFT, + v_align=dui1.VAlign.TOP, + debug=debug, + ), + dui1.Text( + 'TR', + position=(70, 75), + size=(50, 50), + h_align=dui1.HAlign.RIGHT, + v_align=dui1.VAlign.TOP, + debug=debug, + ), + dui1.Text( + 'BL', + position=(-70, -75), + size=(50, 50), + h_align=dui1.HAlign.LEFT, + v_align=dui1.VAlign.BOTTOM, + debug=debug, + ), + dui1.Text( + 'BR', + position=(70, -75), + size=(50, 50), + h_align=dui1.HAlign.RIGHT, + v_align=dui1.VAlign.BOTTOM, + debug=debug, + ), + ], + ), + # Testing custom button images and opacity. + dui1.Button( + label='Test3', + texture='buttonSquareWide', + padding_left=10.0, + padding_right=10.0, + color=(1, 1, 1, 0.3), + size=(200, 100), + ), + # Testing image drawing vs bounds + dui1.Button( + label='BoundsTest', + texture='white', + color=(1, 1, 1, 0.3), + size=(150, 100), + debug=debug, + ), + ], + ), + dui1.ButtonRow( + title='Long Row Test', + subtitle='Look - a subtitle!', + buttons=[ + dui1.Button( + size=(150, 100), + decorations=[ + dui1.Text( + 'MaxWidthTest', + position=(0, 25), + size=(150 * 0.8, 32.0), + flatness=1.0, + shadow=0.0, + debug=debug, + ), + dui1.Text( + 'MaxHeightTest\nSecondLine', + position=(0, -20), + size=(150 * 0.8, 40), + flatness=1.0, + shadow=0.0, + debug=debug, + ), + ], + ), + dui1.Button( + size=(150, 100), + decorations=[ + dui1.Image( + 'zoeIcon', + position=(0, 0), + size=(70, 70), + tint_texture='zoeIconColorMask', + tint_color=(1, 0, 0), + tint2_color=(0, 1, 0), + mask_texture='characterIconMask', + ), + ], + ), + dui1.Button( + size=(150, 100), + decorations=[ + dui1.Image( + 'bridgitPreview', + position=(0, 10), + size=(120, 60), + mask_texture='mapPreviewMask', + mesh_opaque='level_select_button_opaque', + mesh_transparent=( + 'level_select_button_transparent' + ), + ), + ], + ), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button( + 'foo', + size=(150, 100), + scale=0.4, + padding_left=100, + padding_right=200, + ), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + dui1.Button(size=(150, 100)), + ], + ), + dui1.ButtonRow( + spacing_top=-15, + subtitle='Subtitle only!', + buttons=[ + dui1.Button(size=(200, 120)), + ], + ), + dui1.ButtonRow( + buttons=[ + dui1.Button( + 'Row-With-No-Title Test', + size=(300, 80), + style=dui1.ButtonStyle.MEDIUM, + color=(0.8, 0.8, 0.8, 1), + icon='buttonPunch', + icon_color=(0.5, 0.3, 1.0, 1.0), + icon_scale=1.2, + ), + ], + ), + dui1.ButtonRow( + title='Centered Content / Faded Title', + title_color=(0.6, 0.6, 1.0, 0.3), + title_flatness=1.0, + title_shadow=1.0, + subtitle='Testing Centered Title/Content', + subtitle_color=(1.0, 0.5, 1.0, 0.5), + subtitle_flatness=1.0, + subtitle_shadow=0.0, + center_content=True, + center_title=True, + buttons=[ + dui1.Button( + 'Hello There!', + size=(200, 120), + color=(0.7, 0.7, 0.9, 1), + ), + ], + ), + ], + ), + ) + + # Include some client effects if they ask. + if request.args.get('test_effects', False): + response.client_effects = [ + clfx.ScreenMessage( + 'Hello From Response Client Effects', color=(0, 1, 0) + ), + clfx.PlaySound(clfx.Sound.CASH_REGISTER), + clfx.Delay(1.0), + clfx.ScreenMessage( + '{"r":"successText"}', is_lstr=True, color=(0, 1, 0) + ), + clfx.PlaySound(clfx.Sound.CASH_REGISTER), + ] + + # Include a local-action if they ask. + if request.args.get('test_action', False): + response.local_action = 'testaction' + response.local_action_args = {'testparam': 234} + + return response + + +def _test_page_empty( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + import bacommon.docui.v1 as dui1 + + del request # Unused. + + return dui1.Response(page=dui1.Page(title='EmptyPage', rows=[])) + + +def _test_page_display_items( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + """Testing display-items.""" + from bacommon.classic import ClassicChestAppearance, ClassicChestDisplayItem + import bacommon.docui.v1 as dui1 + import bacommon.displayitem as ditm + + # Show some specific debug bits if they ask us to. + debug = bool(request.args.get('debug', False)) + + def _make_test_button( + scale: float, + wrapper: ditm.Wrapper, + ) -> dui1.Button: + + # See how this looks when unrecognized (relying on wrapper info + # only). + uwrapper = copy.deepcopy(wrapper) + uwrapper.item = ditm.Unknown() + + return dui1.Button( + size=(300, 400), + scale=scale, + decorations=[ + dui1.DisplayItem( + wrapper=wrapper, + style=dui1.DisplayItemStyle.FULL, + position=(-62, 100), + size=(120, 120), + debug=debug, + ), + dui1.DisplayItem( + wrapper=uwrapper, + style=dui1.DisplayItemStyle.FULL, + position=(62, 100), + size=(120, 120), + debug=debug, + ), + dui1.DisplayItem( + wrapper=wrapper, + style=dui1.DisplayItemStyle.COMPACT, + position=(-55, -20), + size=(80, 80), + debug=debug, + ), + dui1.DisplayItem( + wrapper=uwrapper, + style=dui1.DisplayItemStyle.COMPACT, + position=(55, -20), + size=(80, 80), + debug=debug, + ), + dui1.DisplayItem( + wrapper=wrapper, + style=dui1.DisplayItemStyle.ICON, + position=(-55, -120), + size=(100, 80), + debug=debug, + ), + dui1.DisplayItem( + wrapper=uwrapper, + style=dui1.DisplayItemStyle.ICON, + position=(55, -120), + size=(100, 80), + debug=debug, + ), + ], + ) + + return dui1.Response( + page=dui1.Page( + padding_left=20, + padding_right=20, + title='DisplayItems', + rows=[ + dui1.ButtonRow( + debug=debug, + padding_left=-10, + title='Display Item Tests', + subtitle=( + 'top=FULL, center=COMPACT, bottom=ICON;' + ' left=regular, right=unknown' + ), + buttons=[ + _make_test_button( + 1.0, + ditm.Wrapper.for_item(ditm.Tickets(count=213)), + ), + _make_test_button( + 0.47, + ditm.Wrapper.for_item(ditm.Tickets(count=213)), + ), + _make_test_button( + 1.0, + ditm.Wrapper.for_item( + ClassicChestDisplayItem( + appearance=ClassicChestAppearance.L3 + ) + ), + ), + _make_test_button( + 1.0, + ditm.Wrapper.for_item(ditm.Tokens(count=3)), + ), + _make_test_button( + 1.0, + ditm.Wrapper.for_item(ditm.Tokens(count=1414287)), + ), + _make_test_button( + 1.0, + ditm.Wrapper.for_item(ditm.Test()), + ), + ], + ), + dui1.ButtonRow( + buttons=[ + dui1.Button( + 'Hide Debug' if debug else 'Show Debug', + style=dui1.ButtonStyle.MEDIUM, + size=(240, 60), + color=(0.6, 0.4, 0.8, 1.0), + action=dui1.Replace( + dui1.Request( + request.path, args={'debug': not debug} + ) + ), + ) + ], + ), + ], + ) + ) + + +def _test_bounds( + request: bacommon.docui.v1.Request, +) -> bacommon.docui.v1.Response: + import bacommon.docui.v1 as dui1 + + del request # Unused. + + def _nm(style: dui1.ButtonStyle) -> str: + return f'{type(style).__name__}.{style.name}' + + return dui1.Response( + page=dui1.Page( + title='BoundsTests', + rows=[ + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.SQUARE), + buttons=[ + dui1.Button('Hello', size=(300, 300), debug=True), + dui1.Button('Hello', size=(200, 200), debug=True), + dui1.Button('Hello', size=(100, 100), debug=True), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.SQUARE_WIDE), + buttons=[ + dui1.Button( + 'Hello', + size=(400, 200), + style=dui1.ButtonStyle.SQUARE_WIDE, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 250), + style=dui1.ButtonStyle.SQUARE_WIDE, + debug=True, + ), + dui1.Button( + 'Hello', + size=(60, 100), + style=dui1.ButtonStyle.SQUARE_WIDE, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title='(background texture)', + buttons=[ + dui1.Button( + 'Hello', + size=(300, 300), + texture='white', + color=(1, 0, 0, 0.3), + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 200), + texture='white', + color=(1, 0, 0, 0.3), + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 100), + texture='white', + color=(1, 0, 0, 0.3), + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.TAB), + buttons=[ + dui1.Button( + 'Hello', + size=(400, 100), + style=dui1.ButtonStyle.TAB, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.TAB, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.TAB, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.LARGER), + buttons=[ + dui1.Button( + 'Hello', + size=(500, 100), + style=dui1.ButtonStyle.LARGER, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.LARGER, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.LARGER, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.LARGE), + buttons=[ + dui1.Button( + 'Hello', + size=(400, 100), + style=dui1.ButtonStyle.LARGE, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.LARGE, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.LARGE, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.MEDIUM), + buttons=[ + dui1.Button( + 'Hello', + size=(300, 100), + style=dui1.ButtonStyle.MEDIUM, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.MEDIUM, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.MEDIUM, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.SMALL), + buttons=[ + dui1.Button( + 'Hello', + size=(200, 100), + style=dui1.ButtonStyle.SMALL, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.SMALL, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.SMALL, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.BACK), + buttons=[ + dui1.Button( + 'Hello', + size=(200, 100), + style=dui1.ButtonStyle.BACK, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.BACK, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.BACK, + debug=True, + ), + ], + ), + dui1.ButtonRow( + title=_nm(dui1.ButtonStyle.BACK_SMALL), + buttons=[ + dui1.Button( + 'Hello', + size=(200, 100), + style=dui1.ButtonStyle.BACK_SMALL, + debug=True, + ), + dui1.Button( + 'Hello', + size=(200, 50), + style=dui1.ButtonStyle.BACK_SMALL, + debug=True, + ), + dui1.Button( + 'Hello', + size=(100, 60), + style=dui1.ButtonStyle.BACK_SMALL, + debug=True, + ), + ], + ), + ], + ) + ) diff --git a/dist/ba_data/python/bauiv1lib/feedback.py b/dist/ba_data/python/bauiv1lib/feedback.py deleted file mode 100644 index cbab894..0000000 --- a/dist/ba_data/python/bauiv1lib/feedback.py +++ /dev/null @@ -1,91 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality related to users rating the game.""" - -from __future__ import annotations - -import bauiv1 as bui - - -def ask_for_rating() -> bui.Widget | None: - """(internal)""" - app = bui.app - assert app.classic is not None - platform = app.classic.platform - subplatform = app.classic.subplatform - - # FIXME: should whitelist platforms we *do* want this for. - if bui.app.env.test: - return None - if not ( - platform == 'mac' - or (platform == 'android' and subplatform in ['google', 'cardboard']) - ): - return None - width = 700 - height = 400 - spacing = 40 - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - dlg = bui.containerwidget( - size=(width, height), - transition='in_right', - scale=( - 1.6 - if uiscale is bui.UIScale.SMALL - else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - ) - v = height - 50 - v -= spacing - v -= 140 - bui.imagewidget( - parent=dlg, - position=(width / 2 - 100, v + 10), - size=(200, 200), - texture=bui.gettexture('cuteSpaz'), - ) - bui.textwidget( - parent=dlg, - position=(15, v - 55), - size=(width - 30, 30), - color=bui.app.ui_v1.infotextcolor, - text=bui.Lstr( - resource='pleaseRateText', - subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], - ), - maxwidth=width * 0.95, - max_height=130, - scale=0.85, - h_align='center', - v_align='center', - ) - - def do_rating() -> None: - # This is not currently in use anywhere. - bui.screenmessage(bui.Lstr(resource='error')) - # bui.open_url(url) - bui.containerwidget(edit=dlg, transition='out_left') - - bui.buttonwidget( - parent=dlg, - position=(60, 20), - size=(200, 60), - label=bui.Lstr(resource='wellSureText'), - autoselect=True, - on_activate_call=do_rating, - ) - - def close() -> None: - bui.containerwidget(edit=dlg, transition='out_left') - - btn = bui.buttonwidget( - parent=dlg, - position=(width - 270, 20), - size=(200, 60), - label=bui.Lstr(resource='noThanksText'), - autoselect=True, - on_activate_call=close, - ) - bui.containerwidget(edit=dlg, cancel_button=btn, selected_child=btn) - return dlg diff --git a/dist/ba_data/python/bauiv1lib/fileselector.py b/dist/ba_data/python/bauiv1lib/fileselector.py index e5f1588..161140c 100644 --- a/dist/ba_data/python/bauiv1lib/fileselector.py +++ b/dist/ba_data/python/bauiv1lib/fileselector.py @@ -249,7 +249,7 @@ class FileSelectorWindow(bui.MainWindow): if duration < min_time: time.sleep(min_time - duration) bui.pushcall( - bui.Call(self._callback, files, None), + bui.CallStrict(self._callback, files, None), from_other_thread=True, ) except Exception as exc: @@ -258,7 +258,7 @@ class FileSelectorWindow(bui.MainWindow): logging.exception('Error in fileselector refresh thread.') nofiles: list[str] = [] bui.pushcall( - bui.Call(self._callback, nofiles, str(exc)), + bui.CallStrict(self._callback, nofiles, str(exc)), from_other_thread=True, ) @@ -431,7 +431,9 @@ class FileSelectorWindow(bui.MainWindow): root_selectable=True, background=False, click_activate=True, - on_activate_call=bui.Call(self._on_entry_activated, entry), + on_activate_call=bui.CallStrict( + self._on_entry_activated, entry + ), ) if num == 0: bui.widget(edit=cnt, up_widget=self._back_button) diff --git a/dist/ba_data/python/bauiv1lib/gather/__init__.py b/dist/ba_data/python/bauiv1lib/gather/__init__.py index fe25edb..3b0ed5e 100644 --- a/dist/ba_data/python/bauiv1lib/gather/__init__.py +++ b/dist/ba_data/python/bauiv1lib/gather/__init__.py @@ -2,7 +2,6 @@ # """Provides UI for inviting/joining friends.""" - from bauiv1lib.gather._gather import GatherTab, GatherWindow __all__ = ['GatherTab', 'GatherWindow'] diff --git a/dist/ba_data/python/bauiv1lib/gather/_gather.py b/dist/ba_data/python/bauiv1lib/gather/_gather.py index 43843de..5d07195 100644 --- a/dist/ba_data/python/bauiv1lib/gather/_gather.py +++ b/dist/ba_data/python/bauiv1lib/gather/_gather.py @@ -212,7 +212,7 @@ class GatherWindow(bui.MainWindow): self._scroll_left + tab_inset, self._scroll_bottom + self._scroll_height - 4.0, ), - on_select_call=bui.WeakCall(self._set_tab), + on_select_call=bui.WeakCallPartial(self._set_tab), ) # Now instantiate handlers for these tabs. diff --git a/dist/ba_data/python/bauiv1lib/gather/abouttab.py b/dist/ba_data/python/bauiv1lib/gather/abouttab.py index bd5442d..55ab7fc 100644 --- a/dist/ba_data/python/bauiv1lib/gather/abouttab.py +++ b/dist/ba_data/python/bauiv1lib/gather/abouttab.py @@ -160,7 +160,7 @@ class AboutGatherTab(GatherTab): fallback_resource='gatherWindow.getFriendInviteCodeText', ), autoselect=True, - on_activate_call=bui.WeakCall(self._invite_to_try_press), + on_activate_call=bui.WeakCallStrict(self._invite_to_try_press), up_widget=tab_button, show_buffer_top=500, ) @@ -190,7 +190,9 @@ class AboutGatherTab(GatherTab): textcolor=(0.6, 0.6, 1), label=bui.Lstr(resource='discordJoinText'), autoselect=True, - on_activate_call=bui.WeakCall(self._join_the_discord_press), + on_activate_call=bui.WeakCallStrict( + self._join_the_discord_press + ), up_widget=( invite_button if invite_button is not None else tab_button ), diff --git a/dist/ba_data/python/bauiv1lib/gather/manualtab.py b/dist/ba_data/python/bauiv1lib/gather/manualtab.py index 5b38a89..cb24238 100644 --- a/dist/ba_data/python/bauiv1lib/gather/manualtab.py +++ b/dist/ba_data/python/bauiv1lib/gather/manualtab.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Defines the manual tab in the gather UI.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -10,11 +11,13 @@ from enum import Enum from threading import Thread from dataclasses import dataclass from typing import TYPE_CHECKING, cast, override -from bauiv1lib.gather import GatherTab +from bacommon.analytics import ClassicAnalyticsEvent import bauiv1 as bui import bascenev1 as bs +from bauiv1lib.gather import GatherTab + if TYPE_CHECKING: from typing import Any, Callable @@ -323,7 +326,7 @@ class ManualGatherTab(GatherTab): label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'), position=(c_width * 0.5 - 300, v), autoselect=True, - on_activate_call=bui.Call(self._connect, txt, txt2), + on_activate_call=bui.CallStrict(self._connect, txt, txt2), ) savebutton = bui.buttonwidget( parent=self._container, @@ -332,7 +335,7 @@ class ManualGatherTab(GatherTab): label=bui.Lstr(resource='gatherWindow.favoritesSaveText'), position=(c_width * 0.5 - 240 + 490 - 200, v), autoselect=True, - on_activate_call=bui.Call(self._save_server, txt, txt2), + on_activate_call=bui.CallStrict(self._save_server, txt, txt2), ) bui.widget(edit=btn, right_widget=savebutton) bui.widget(edit=savebutton, left_widget=btn, up_widget=txt2) @@ -353,7 +356,7 @@ class ManualGatherTab(GatherTab): color=(0.5, 0.9, 0.5), scale=0.8, selectable=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._on_show_my_address_button_press, v, self._container, @@ -516,7 +519,7 @@ class ManualGatherTab(GatherTab): _HostLookupThread( name=config['addr'], port=config['port'], - call=bui.WeakCall(self._host_lookup_result), + call=bui.WeakCallPartial(self._host_lookup_result), ).start() def _on_favorites_edit_press(self) -> None: @@ -637,7 +640,7 @@ class ManualGatherTab(GatherTab): cbtn = bui.buttonwidget( parent=cnt, label=bui.Lstr(resource='cancelText'), - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( lambda c: bui.containerwidget(edit=c, transition='out_scale'), cnt, ), @@ -650,7 +653,7 @@ class ManualGatherTab(GatherTab): label=bui.Lstr(resource='saveText'), size=(180, 60), position=(c_width - 230, 30), - on_activate_call=bui.Call(self._edit_saved_party), + on_activate_call=bui.CallStrict(self._edit_saved_party), autoselect=True, ) bui.widget(edit=cbtn, right_widget=okb) @@ -754,7 +757,7 @@ class ManualGatherTab(GatherTab): selectable=True, color=(1.0, 1, 0.4), always_highlight=True, - on_select_call=bui.Call(self._on_favorite_select, server), + on_select_call=bui.CallStrict(self._on_favorite_select, server), on_activate_call=self._favorites_connect_button.activate, text=( config['Saved Servers'][server]['name'] @@ -803,6 +806,13 @@ class ManualGatherTab(GatherTab): def _connect( self, textwidget: bui.Widget, port_textwidget: bui.Widget ) -> None: + + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.JOIN_PARTY_BY_ADDRESS + ) + ) + addr = cast(str, bui.textwidget(query=textwidget)) if addr == '': bui.screenmessage( @@ -824,7 +834,9 @@ class ManualGatherTab(GatherTab): return _HostLookupThread( - name=addr, port=port, call=bui.WeakCall(self._host_lookup_result) + name=addr, + port=port, + call=bui.WeakCallPartial(self._host_lookup_result), ).start() def _save_server( @@ -906,7 +918,7 @@ class ManualGatherTab(GatherTab): val = sock.getsockname()[0] sock.close() bui.pushcall( - bui.Call( + bui.CallStrict( _safe_set_text, self._checking_state_text, val, @@ -918,7 +930,7 @@ class ManualGatherTab(GatherTab): if is_udp_communication_error(exc): bui.pushcall( - bui.Call( + bui.CallStrict( _safe_set_text, self._checking_state_text, bui.Lstr(resource='gatherWindow.' 'noConnectionText'), @@ -928,7 +940,7 @@ class ManualGatherTab(GatherTab): ) else: bui.pushcall( - bui.Call( + bui.CallStrict( _safe_set_text, self._checking_state_text, bui.Lstr( @@ -1053,7 +1065,7 @@ class ManualGatherTab(GatherTab): self._access_check_count = 0 # Cap our refreshes eventually. self._access_check_timer = bui.AppTimer( 10.0, - bui.WeakCall( + bui.WeakCallStrict( self._access_check_update, t_addr, t_accessible, @@ -1086,7 +1098,7 @@ class ManualGatherTab(GatherTab): bui.app.classic.master_server_v1_get( 'bsAccessCheck', {'b': bui.app.env.engine_build_number}, - callback=bui.WeakCall(self._on_accessible_response), + callback=bui.WeakCallPartial(self._on_accessible_response), ) def _on_accessible_response(self, data: dict[str, Any] | None) -> None: diff --git a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py index 013e2a2..621bda8 100644 --- a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py +++ b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py @@ -7,6 +7,7 @@ from __future__ import annotations import weakref from typing import TYPE_CHECKING, override +from bacommon.analytics import ClassicAnalyticsEvent import bauiv1 as bui import bascenev1 as bs @@ -44,14 +45,17 @@ class NetScanner: bui.widget(edit=self._columnwidget, up_widget=tab_button) self._width = width self._last_selected_host: dict[str, Any] | None = None + self._last_scan: list[dict[str, str]] | None = None self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self.update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) - # Go ahead and run a few *almost* immediately so we don't - # have to wait a second. - self.update() - bui.apptimer(0.25, bui.WeakCall(self.update)) + # Run two cycles pretty immediately - this should send out a + # "who's there" and update the list with any immediate-ish + # results so we may not have to wait a second to see things + # appear. + self._update() + bui.apptimer(0.25, bui.WeakCallStrict(self._update)) def __del__(self) -> None: bs.end_host_scanning() @@ -61,37 +65,53 @@ class NetScanner: def _on_activate(self, host: dict[str, Any]) -> None: + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.JOIN_NEARBY_PARTY + ) + ) + # Store UI location to return to when done. if bs.app.classic is not None: bs.app.classic.save_ui_state() bs.connect_to_party(host['address']) - def update(self) -> None: + def _update(self) -> None: """(internal)""" # In case our UI was killed from under us. if not self._columnwidget: - print( - f'ERROR: NetScanner running without UI at time {bui.apptime()}.' + bui.uilog.error( + 'nearbytab NetScanner running without UI at time %s.', + bui.apptime(), ) return + hosts = bs.host_scan_cycle() + + # If nothing has changed since our last run, do nothing. If we + # do redundant rebuilds then we are likely to lose some clicks + # due to rebuilding after a click starts but before it ends. + if hosts == self._last_scan: + return + + self._last_scan = hosts + t_scale = 1.6 for child in self._columnwidget.get_children(): child.delete() # Grab this now this since adding widgets will change it. last_selected_host = self._last_selected_host - hosts = bs.host_scan_cycle() for i, host in enumerate(hosts): txt3 = bui.textwidget( parent=self._columnwidget, size=(self._width / t_scale, 30), selectable=True, color=(1, 1, 1), - on_select_call=bui.Call(self._on_select, host), - on_activate_call=bui.Call(self._on_activate, host), + on_select_call=bui.CallStrict(self._on_select, host), + on_activate_call=bui.CallStrict(self._on_activate, host), click_activate=True, text=host['display_string'], h_align='left', diff --git a/dist/ba_data/python/bauiv1lib/gather/privatetab.py b/dist/ba_data/python/bauiv1lib/gather/privatetab.py index 6b4bf9f..8ec6a23 100644 --- a/dist/ba_data/python/bauiv1lib/gather/privatetab.py +++ b/dist/ba_data/python/bauiv1lib/gather/privatetab.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, cast, override from efro.error import CommunicationError from efro.dataclassio import dataclass_from_dict, dataclass_to_dict +from bacommon.analytics import ClassicAnalyticsEvent import bacommon.cloud from bacommon.net import ( PrivateHostingState, @@ -63,7 +64,7 @@ class PrivateGatherTab(GatherTab): self._state: State = State() self._last_datacode_refresh_time: float | None = None self._hostingstate = PrivateHostingState() - self._v2state: bacommon.bs.PrivatePartyResponse | None = None + self._v2state: bacommon.classic.PrivatePartyResponse | None = None self._join_sub_tab_text: bui.Widget | None = None self._host_sub_tab_text: bui.Widget | None = None self._update_timer: bui.AppTimer | None = None @@ -165,7 +166,7 @@ class PrivateGatherTab(GatherTab): ) self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) # Prevent taking any action until we've updated our state. @@ -322,7 +323,7 @@ class PrivateGatherTab(GatherTab): 'type': 'PRIVATE_PARTY_QUERY', 'expire_time': time.time() + 20, }, - callback=bui.WeakCall( + callback=bui.WeakCallPartial( self._idle_hosting_state_response ), ) @@ -341,7 +342,7 @@ class PrivateGatherTab(GatherTab): if plus.accounts.primary is not None: with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.PrivatePartyMessage( + bacommon.classic.PrivatePartyMessage( need_datacode=( self._last_datacode_refresh_time is None or time.monotonic() @@ -349,7 +350,7 @@ class PrivateGatherTab(GatherTab): > 30.0 ) ), - on_response=bui.WeakCall( + on_response=bui.WeakCallPartial( self._on_private_party_query_response ), ) @@ -357,7 +358,7 @@ class PrivateGatherTab(GatherTab): self._last_v2_state_query_time = now def _on_private_party_query_response( - self, response: bacommon.bs.PrivatePartyResponse | Exception + self, response: bacommon.classic.PrivatePartyResponse | Exception ) -> None: if isinstance(response, Exception): self._debug_server_comm('got pp v2 state response (err)') @@ -948,6 +949,13 @@ class PrivateGatherTab(GatherTab): ) def _connect_to_party_code(self, code: str) -> None: + + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.JOIN_PRIVATE_PARTY + ) + ) + # Ignore attempted followup sends for a few seconds (this will # reset if we get a response). plus = bui.app.plus @@ -971,7 +979,7 @@ class PrivateGatherTab(GatherTab): 'expire_time': time.time() + 20, 'code': code, }, - callback=bui.WeakCall(self._connect_response), + callback=bui.WeakCallPartial(self._connect_response), ) plus.run_v1_account_transactions() @@ -1029,7 +1037,7 @@ class PrivateGatherTab(GatherTab): 'expire_time': time.time() + 20, 'datacode': self._v2state.datacode, }, - callback=bui.WeakCall(self._hosting_state_response), + callback=bui.WeakCallPartial(self._hosting_state_response), ) plus.run_v1_account_transactions() @@ -1040,7 +1048,7 @@ class PrivateGatherTab(GatherTab): 'type': 'PRIVATE_PARTY_STOP', 'expire_time': time.time() + 20, }, - callback=bui.WeakCall(self._hosting_state_response), + callback=bui.WeakCallPartial(self._hosting_state_response), ) plus.run_v1_account_transactions() bui.getsound('click01').play() diff --git a/dist/ba_data/python/bauiv1lib/gather/publictab.py b/dist/ba_data/python/bauiv1lib/gather/publictab.py index 8aa82bf..ee43692 100644 --- a/dist/ba_data/python/bauiv1lib/gather/publictab.py +++ b/dist/ba_data/python/bauiv1lib/gather/publictab.py @@ -12,6 +12,7 @@ from enum import Enum from dataclasses import dataclass from typing import TYPE_CHECKING, cast, override +from bacommon.analytics import ClassicAnalyticsEvent from bauiv1lib.gather import GatherTab import bauiv1 as bui import bascenev1 as bs @@ -117,11 +118,13 @@ class UIRow: size=(sub_scroll_width * 0.46, 20), position=(0 + hpos, 4 + vpos), selectable=True, - on_select_call=bui.WeakCall( + on_select_call=bui.WeakCallStrict( tab.set_public_party_selection, Selection(party.get_key(), SelectionComponent.NAME), ), - on_activate_call=bui.WeakCall(tab.on_public_party_activate, party), + on_activate_call=bui.WeakCallStrict( + tab.on_public_party_activate, party + ), click_activate=True, maxwidth=sub_scroll_width * 0.45, corner_scale=1.4, @@ -160,8 +163,8 @@ class UIRow: label=bui.Lstr(resource='statsText'), parent=columnwidget, autoselect=True, - on_activate_call=bui.Call(bui.open_url, url), - on_select_call=bui.WeakCall( + on_activate_call=bui.CallStrict(bui.open_url, url), + on_select_call=bui.WeakCallStrict( tab.set_public_party_selection, Selection(party.get_key(), SelectionComponent.STATS_BUTTON), ), @@ -268,7 +271,9 @@ class AddrFetchThread(Thread): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(('8.8.8.8', 80)) val = sock.getsockname()[0] - bui.pushcall(bui.Call(self._call, val), from_other_thread=True) + bui.pushcall( + bui.CallStrict(self._call, val), from_other_thread=True + ) except Exception as exc: from efro.error import is_udp_communication_error @@ -334,7 +339,7 @@ class PingThread(Thread): time.sleep(1) ping = (time.time() - starttime) * 1000.0 bui.pushcall( - bui.Call( + bui.CallStrict( self._call, self._address, self._port, @@ -497,11 +502,13 @@ class PublicGatherTab(GatherTab): # Attempt to fetch our local address so we have it for error # messages. if self._local_address is None: - AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() + AddrFetchThread( + bui.WeakCallPartial(self._fetch_local_addr_cb) + ).start() self._set_sub_tab(self._sub_tab, region_width, region_height) self._update_timer = bui.AppTimer( - 0.1, bui.WeakCall(self._update), repeat=True + 0.1, bui.WeakCallStrict(self._update), repeat=True ) return self._container @@ -814,7 +821,7 @@ class PublicGatherTab(GatherTab): parent=self._container, id=f'{self._idprefix}|maxsizeminus', size=(40, 40), - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._on_max_public_party_size_minus_press ), position=(280 + xoffs, v - 26), @@ -825,7 +832,7 @@ class PublicGatherTab(GatherTab): parent=self._container, id=f'{self._idprefix}|maxsizeplus', size=(40, 40), - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._on_max_public_party_size_plus_press ), position=(350 + xoffs, v - 26), @@ -1154,7 +1161,7 @@ class PublicGatherTab(GatherTab): # Now, new or not, update its values. party.queue = party_in.get('q') - assert isinstance(party.queue, (str, type(None))) + assert isinstance(party.queue, str | None) party.port = port party.name = party_in['n'] assert isinstance(party.name, str) @@ -1167,7 +1174,7 @@ class PublicGatherTab(GatherTab): party.ping_interval = 0.001 * party_in['pi'] assert isinstance(party.ping_interval, float) party.stats_addr = party_in['sa'] - assert isinstance(party.stats_addr, (str, type(None))) + assert isinstance(party.stats_addr, str | None) # Make sure the party's UI gets updated. party.clean_display_index = None @@ -1255,7 +1262,9 @@ class PublicGatherTab(GatherTab): 'proto': bs.protocol_version(), 'lang': bui.app.lang.language, }, - callback=bui.WeakCall(self._on_public_party_query_result), + callback=bui.WeakCallPartial( + self._on_public_party_query_result + ), ) plus.run_v1_account_transactions() else: @@ -1297,7 +1306,9 @@ class PublicGatherTab(GatherTab): party.ping_attempts += 1 PingThread( - party.address, party.port, bui.WeakCall(self._ping_callback) + party.address, + party.port, + bui.WeakCallPartial(self._ping_callback), ).start() def _ping_callback( @@ -1413,7 +1424,9 @@ class PublicGatherTab(GatherTab): bui.app.classic.master_server_v1_get( 'bsAccessCheck', {'b': bui.app.env.engine_build_number}, - callback=bui.WeakCall(self._on_public_party_accessible_response), + callback=bui.WeakCallPartial( + self._on_public_party_accessible_response + ), ) def _on_start_advertizing_press(self) -> None: @@ -1483,6 +1496,13 @@ class PublicGatherTab(GatherTab): def on_public_party_activate(self, party: PartyEntry) -> None: """Called when a party is clicked or otherwise activated.""" self.save_state() + + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.JOIN_PUBLIC_PARTY + ) + ) + if party.queue is not None: from bauiv1lib.partyqueue import PartyQueueWindow diff --git a/dist/ba_data/python/bauiv1lib/gettokens.py b/dist/ba_data/python/bauiv1lib/gettokens.py index 7602d03..1493004 100644 --- a/dist/ba_data/python/bauiv1lib/gettokens.py +++ b/dist/ba_data/python/bauiv1lib/gettokens.py @@ -6,15 +6,13 @@ from __future__ import annotations import time from enum import Enum -from functools import partial from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never, override import bacommon.cloud -import bacommon.bs +import bacommon.classic import bauiv1 as bui - if TYPE_CHECKING: from typing import Any, Callable @@ -73,6 +71,9 @@ class GetTokensWindow(bui.MainWindow): auxiliary_style: bool = True, ): # pylint: disable=too-many-locals + + self._auxiliary_style = auxiliary_style + self._uiopenstate = bui.UIOpenState('gettokens') bwidthstd = 170 bwidthwide = 300 ycolor = (0, 0, 0.3) @@ -107,7 +108,12 @@ class GetTokensWindow(bui.MainWindow): _TxtDef( bui.Lstr( resource='tokens.numTokensText', - subs=[('${COUNT}', str(bacommon.bs.TOKENS1_COUNT))], + subs=[ + ( + '${COUNT}', + str(bacommon.classic.TOKENS1_COUNT), + ) + ], ), pos=(bwidthstd * 0.5, pos1), color=(1.1, 1.05, 1.0), @@ -147,7 +153,12 @@ class GetTokensWindow(bui.MainWindow): _TxtDef( bui.Lstr( resource='tokens.numTokensText', - subs=[('${COUNT}', str(bacommon.bs.TOKENS2_COUNT))], + subs=[ + ( + '${COUNT}', + str(bacommon.classic.TOKENS2_COUNT), + ) + ], ), pos=(bwidthstd * 0.5, pos1), color=(1.1, 1.05, 1.0), @@ -187,7 +198,12 @@ class GetTokensWindow(bui.MainWindow): _TxtDef( bui.Lstr( resource='tokens.numTokensText', - subs=[('${COUNT}', str(bacommon.bs.TOKENS3_COUNT))], + subs=[ + ( + '${COUNT}', + str(bacommon.classic.TOKENS3_COUNT), + ) + ], ), pos=(bwidthstd * 0.5, pos1), color=(1.1, 1.05, 1.0), @@ -227,7 +243,12 @@ class GetTokensWindow(bui.MainWindow): _TxtDef( bui.Lstr( resource='tokens.numTokensText', - subs=[('${COUNT}', str(bacommon.bs.TOKENS4_COUNT))], + subs=[ + ( + '${COUNT}', + str(bacommon.classic.TOKENS4_COUNT), + ) + ], ), pos=(bwidthstd * 0.5, pos1), color=(1.1, 1.05, 1.0), @@ -344,7 +365,7 @@ class GetTokensWindow(bui.MainWindow): color=(0.3, 0.23, 0.36), scale=scale, toolbar_visibility=( - 'get_tokens' + 'menu_tokens' if uiscale is bui.UIScale.SMALL else 'menu_full' ), @@ -433,7 +454,7 @@ class GetTokensWindow(bui.MainWindow): self._state = self.State.LOADING self._update_timer = bui.AppTimer( - 0.789, bui.WeakCall(self._update), repeat=True + 0.789, bui.WeakCallStrict(self._update), repeat=True ) self._update() @@ -441,9 +462,16 @@ class GetTokensWindow(bui.MainWindow): def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) + + # Pull everything out of self here. If we do it below in the lambda, + # we'll keep self alive which is bad. + auxiliary_style = self._auxiliary_style + return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( - transition=transition, origin_widget=origin_widget + transition=transition, + origin_widget=origin_widget, + auxiliary_style=auxiliary_style, ) ) @@ -470,7 +498,9 @@ class GetTokensWindow(bui.MainWindow): with plus.accounts.primary: plus.cloud.send_message_cb( bacommon.cloud.StoreQueryMessage(), - on_response=bui.WeakCall(self._on_store_query_response), + on_response=bui.WeakCallPartial( + self._on_store_query_response + ), ) # Can't do much until we get a store state. @@ -615,7 +645,7 @@ class GetTokensWindow(bui.MainWindow): scale=0.8, color=(0.4, 0.25, 0.5), textcolor=self._textcolor, - on_activate_call=partial( + on_activate_call=bui.WeakCallStrict( self._on_learn_more_press, response.token_info_url ), ) @@ -650,7 +680,7 @@ class GetTokensWindow(bui.MainWindow): size=(buttondef.width, 275), position=(x, -10 + yoffs), button_type='square', - on_activate_call=partial( + on_activate_call=bui.WeakCallStrict( self._purchase_press, buttondef.itemid ), ) @@ -786,7 +816,7 @@ class GetTokensWindow(bui.MainWindow): bui.open_url(url) -def show_get_tokens_prompt() -> None: +def show_get_tokens_prompt(origin_widget: bui.Widget | None = None) -> None: """Show a 'not enough tokens' prompt with an option to purchase more. Note that the purchase option may not always be available @@ -796,14 +826,19 @@ def show_get_tokens_prompt() -> None: assert bui.app.classic is not None + get_tokens_button = bui.get_special_widget('get_tokens_button') + # Currently always allowing token purchases. if bool(True): ConfirmWindow( bui.Lstr(resource='tokens.notEnoughTokensText'), - show_get_tokens_window, + bui.CallStrict( + show_get_tokens_window, origin_widget=get_tokens_button + ), ok_text=bui.Lstr(resource='tokens.getTokensText'), width=460, height=130, + origin_widget=origin_widget, ) else: ConfirmWindow( @@ -811,39 +846,45 @@ def show_get_tokens_prompt() -> None: cancel_button=False, width=460, height=130, + origin_widget=origin_widget, ) -def show_get_tokens_window(origin_widget: bui.Widget | None = None) -> None: +def show_get_tokens_window( + origin_widget: bui.Widget | None = None, toggle: bool = False +) -> None: """Transition to the get-tokens main-window from anywhere.""" - # NOTE TO USERS: The code below is not the proper way to do things; + # NOTE TO USERS: The code below is not the standard way to do things; # whenever possible one should use a MainWindow's # main_window_replace() or main_window_back() methods or # bauiv1.auxiliary_window_activate(). We just need to do things a # bit more manually in this particular case. - # Basically we want to pop up our auxiliary window but we don't want - # to replace any existing auxiliary windows; we want our close - # button to go back to whatever was there already, no matter whether - # it was an auxiliary window or not. + # Basically we want to push our window on to the stack from + # anywhere so we can go back to where we were once done even if it + # was an auxiliary window. prev_main_window = bui.app.ui_v1.get_main_window() # Special-case: If it seems we're already in the window, do nothing. if isinstance(prev_main_window, GetTokensWindow): + if toggle: + prev_main_window.main_window_back() return ui = bui.app.ui_v1 - # Set our new main window. + # Set our new main window. Note that we pass auxiliary_style=False + # so that we get a back button instead of a close button. ui.set_main_window( - GetTokensWindow(origin_widget=origin_widget), + GetTokensWindow(origin_widget=origin_widget, auxiliary_style=False), from_window=False, # Don't check where we're coming from. back_state=ui.save_current_main_window_state(), - is_auxiliary=True, + is_auxiliary=False, suppress_warning=True, + extra_type_id='', ) # Transition out any previous main window. if prev_main_window is not None: - prev_main_window.main_window_close() + prev_main_window.main_window_close(transition='out_left') diff --git a/dist/ba_data/python/bauiv1lib/help.py b/dist/ba_data/python/bauiv1lib/help.py index 73f7ee0..cda1b6b 100644 --- a/dist/ba_data/python/bauiv1lib/help.py +++ b/dist/ba_data/python/bauiv1lib/help.py @@ -447,7 +447,9 @@ class HelpWindow(bui.MainWindow): color=(1, 0.7, 0.3), selectable=False, enable_sound=False, - on_activate_call=bui.WeakCall(self._play_sound, 'spazAttack0', 4), + on_activate_call=bui.WeakCallStrict( + self._play_sound, 'spazAttack0', 4 + ), ) txt_scale = getres(f'{self._r}.punchInfoTextScale') @@ -475,7 +477,9 @@ class HelpWindow(bui.MainWindow): color=(1, 0.3, 0.3), selectable=False, enable_sound=False, - on_activate_call=bui.WeakCall(self._play_sound, 'explosion0', 5), + on_activate_call=bui.WeakCallStrict( + self._play_sound, 'explosion0', 5 + ), ) txt = bui.Lstr(resource=f'{self._r}.bombInfoText').evaluate() @@ -504,7 +508,9 @@ class HelpWindow(bui.MainWindow): color=(0.5, 0.5, 1), selectable=False, enable_sound=False, - on_activate_call=bui.WeakCall(self._play_sound, 'spazPickup0', 1), + on_activate_call=bui.WeakCallStrict( + self._play_sound, 'spazPickup0', 1 + ), ) txtl = bui.Lstr(resource=f'{self._r}.pickUpInfoText') @@ -532,7 +538,9 @@ class HelpWindow(bui.MainWindow): color=(0.4, 1, 0.4), selectable=False, enable_sound=False, - on_activate_call=bui.WeakCall(self._play_sound, 'spazJump0', 4), + on_activate_call=bui.WeakCallStrict( + self._play_sound, 'spazJump0', 4 + ), ) txt = bui.Lstr(resource=f'{self._r}.jumpInfoText').evaluate() diff --git a/dist/ba_data/python/bauiv1lib/iconpicker.py b/dist/ba_data/python/bauiv1lib/iconpicker.py index 0a9bd4b..9c3301f 100644 --- a/dist/ba_data/python/bauiv1lib/iconpicker.py +++ b/dist/ba_data/python/bauiv1lib/iconpicker.py @@ -130,7 +130,7 @@ class IconPicker(PopupWindow): text_scale=1.2, label='', color=(0.65, 0.65, 0.65), - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._select_icon, self._icons[index] ), position=pos, @@ -176,7 +176,7 @@ class IconPicker(PopupWindow): plus = bui.app.plus assert plus is not None - if plus.get_v1_account_state() != 'signed_in': + if plus.accounts.primary is None: show_sign_in_prompt() return diff --git a/dist/ba_data/python/bauiv1lib/inbox.py b/dist/ba_data/python/bauiv1lib/inbox.py index 1ec082d..fc5c589 100644 --- a/dist/ba_data/python/bauiv1lib/inbox.py +++ b/dist/ba_data/python/bauiv1lib/inbox.py @@ -12,7 +12,9 @@ from typing import override, assert_never, TYPE_CHECKING from efro.util import strict_partial, pairs_from_flat from efro.error import CommunicationError -import bacommon.bs +import bacommon.clouddialog as cdlg +import bacommon.clouddialog.basic as bcdlg +import bacommon.classic from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui @@ -20,6 +22,8 @@ if TYPE_CHECKING: import datetime from typing import Callable + import bacommon.displayitem as ditm + class _Section: def get_height(self) -> float: @@ -164,7 +168,7 @@ class _DisplayItemsSection(_Section): self, *, sub_width: float, - items: list[bacommon.bs.DisplayItemWrapper], + items: list[ditm.Wrapper], width: float = 100.0, spacing_top: float = 0.0, spacing_bottom: float = 0.0, @@ -286,15 +290,17 @@ class _ExpireTimeSection(_Section): h_align='center', v_align='center', ) - self._timer = bui.AppTimer(1.0, bui.WeakCall(self._update), repeat=True) + self._timer = bui.AppTimer( + 1.0, bui.WeakCallStrict(self._update), repeat=True + ) self._update() @dataclass class _EntryDisplay: - interaction_style: bacommon.bs.BasicCloudDialog.InteractionStyle - button_label_positive: bacommon.bs.BasicCloudDialog.ButtonLabel - button_label_negative: bacommon.bs.BasicCloudDialog.ButtonLabel + interaction_style: bcdlg.InteractionStyle + button_label_positive: bcdlg.ButtonLabel + button_label_negative: bcdlg.ButtonLabel sections: list[_Section] id: str total_height: float @@ -320,6 +326,8 @@ class InboxWindow(bui.MainWindow): assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale + self._uiopenstate = bui.UIOpenState('classicinbox') + self._action_ui_pause: bui.RootUIUpdatePause | None = None self._entry_displays: list[_EntryDisplay] = [] @@ -487,8 +495,10 @@ class InboxWindow(bui.MainWindow): with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.InboxRequestMessage(), - on_response=bui.WeakCall(self._on_inbox_request_response), + bacommon.classic.InboxRequestMessage(), + on_response=bui.WeakCallPartial( + self._on_inbox_request_response + ), ) @override @@ -517,7 +527,7 @@ class InboxWindow(bui.MainWindow): def _on_entry_display_press( self, display_weak: weakref.ReferenceType[_EntryDisplay], - action: bacommon.bs.CloudDialogAction, + action: cdlg.Action, ) -> None: display = display_weak() if display is None: @@ -529,10 +539,7 @@ class InboxWindow(bui.MainWindow): # We currently only recognize basic entries and their possible # interaction types. - if ( - display.interaction_style - is bacommon.bs.BasicCloudDialog.InteractionStyle.UNKNOWN - ): + if display.interaction_style is bcdlg.InteractionStyle.UNKNOWN: display.processing_complete = True self._close_soon_if_all_processed() return @@ -554,8 +561,8 @@ class InboxWindow(bui.MainWindow): # Ask the master-server to run our action. with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.CloudDialogActionMessage(display.id, action), - on_response=bui.WeakCall( + cdlg.ActionMessage(display.id, action), + on_response=bui.WeakCallPartial( self._on_client_ui_action_response, display_weak, action, @@ -565,12 +572,12 @@ class InboxWindow(bui.MainWindow): # Tweak the UI to show that things are in motion. button = ( display.button_positive - if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE + if action is cdlg.Action.BUTTON_PRESS_POSITIVE else display.button_negative ) button_spinner = ( display.button_spinner_positive - if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE + if action is cdlg.Action.BUTTON_PRESS_POSITIVE else display.button_spinner_negative ) if button is not None: @@ -579,7 +586,7 @@ class InboxWindow(bui.MainWindow): bui.spinnerwidget(edit=button_spinner, visible=True) def _close_soon_if_all_processed(self) -> None: - bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed)) + bui.apptimer(0.25, bui.WeakCallStrict(self._close_if_all_processed)) def _close_if_all_processed(self) -> None: if not all(m.processing_complete for m in self._entry_displays): @@ -609,8 +616,8 @@ class InboxWindow(bui.MainWindow): def _on_client_ui_action_response( self, display_weak: weakref.ReferenceType[_EntryDisplay], - action: bacommon.bs.CloudDialogAction, - response: bacommon.bs.CloudDialogActionResponse | Exception, + action: cdlg.Action, + response: cdlg.ActionResponse | Exception, ) -> None: # pylint: disable=too-many-branches @@ -633,12 +640,12 @@ class InboxWindow(bui.MainWindow): # Tweak the button to show results. button = ( display.button_positive - if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE + if action is cdlg.Action.BUTTON_PRESS_POSITIVE else display.button_negative ) button_spinner = ( display.button_spinner_positive - if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE + if action is cdlg.Action.BUTTON_PRESS_POSITIVE else display.button_spinner_negative ) # Always hide spinner at this point. @@ -690,7 +697,7 @@ class InboxWindow(bui.MainWindow): bui.buttonwidget(edit=button, label=label) def _on_inbox_request_response( - self, response: bacommon.bs.InboxRequestResponse | Exception + self, response: bacommon.classic.InboxRequestResponse | Exception ) -> None: # pylint: disable=too-many-locals # pylint: disable=too-many-statements @@ -716,7 +723,7 @@ class InboxWindow(bui.MainWindow): self._error(errmsg) return - assert isinstance(response, bacommon.bs.InboxRequestResponse) + assert isinstance(response, bacommon.classic.InboxRequestResponse) # If we got no messages, don't touch anything. This keeps # keyboard control working in the empty case. @@ -759,9 +766,9 @@ class InboxWindow(bui.MainWindow): # textfin: str color: tuple[float, float, float] - interaction_style: bacommon.bs.BasicCloudDialog.InteractionStyle - button_label_positive: bacommon.bs.BasicCloudDialog.ButtonLabel - button_label_negative: bacommon.bs.BasicCloudDialog.ButtonLabel + interaction_style: bcdlg.InteractionStyle + button_label_positive: bcdlg.ButtonLabel + button_label_negative: bcdlg.ButtonLabel sections: list[_Section] = [] total_height = 80.0 @@ -769,7 +776,7 @@ class InboxWindow(bui.MainWindow): # Display only entries where we recognize all style/label # values and ui component types. if ( - isinstance(wrapper.ui, bacommon.bs.BasicCloudDialog) + isinstance(wrapper.ui, bcdlg.BasicCloudDialog) and not wrapper.ui.contains_unknown_elements() ): color = (0.55, 0.5, 0.7) @@ -777,15 +784,13 @@ class InboxWindow(bui.MainWindow): button_label_positive = wrapper.ui.button_label_positive button_label_negative = wrapper.ui.button_label_negative - idcls = bacommon.bs.BasicCloudDialogComponentTypeID + idcls = bcdlg.ComponentTypeID for component in wrapper.ui.components: ctypeid = component.get_type_id() section: _Section if ctypeid is idcls.TEXT: - assert isinstance( - component, bacommon.bs.BasicCloudDialogComponentText - ) + assert isinstance(component, bcdlg.Text) section = _TextSection( sub_width=sub_width, text=bui.Lstr( @@ -801,9 +806,7 @@ class InboxWindow(bui.MainWindow): sections.append(section) elif ctypeid is idcls.LINK: - assert isinstance( - component, bacommon.bs.BasicCloudDialogComponentLink - ) + assert isinstance(component, bcdlg.Link) def _do_open_url(url: str, sec: _ButtonSection) -> None: del sec # Unused. @@ -827,7 +830,7 @@ class InboxWindow(bui.MainWindow): elif ctypeid is idcls.DISPLAY_ITEMS: assert isinstance( component, - bacommon.bs.BasicCloudDialogDisplayItems, + bcdlg.DisplayItems, ) section = _DisplayItemsSection( sub_width=sub_width, @@ -844,7 +847,7 @@ class InboxWindow(bui.MainWindow): assert isinstance( component, - bacommon.bs.BasicCloudDialogBsClassicTourneyResult, + bcdlg.ClassicTourneyResult, ) campaignname, levelname = component.game.split(':') assert bui.app.classic is not None @@ -968,9 +971,7 @@ class InboxWindow(bui.MainWindow): sections.append(section) elif ctypeid is idcls.EXPIRE_TIME: - assert isinstance( - component, bacommon.bs.BasicCloudDialogExpireTime - ) + assert isinstance(component, bcdlg.ExpireTime) section = _ExpireTimeSection( sub_width=sub_width, time=component.time, @@ -991,15 +992,9 @@ class InboxWindow(bui.MainWindow): # Display anything with unknown components as an # 'upgrade your app to see this' message. color = (0.6, 0.6, 0.6) - interaction_style = ( - bacommon.bs.BasicCloudDialog.InteractionStyle.UNKNOWN - ) - button_label_positive = ( - bacommon.bs.BasicCloudDialog.ButtonLabel.OK - ) - button_label_negative = ( - bacommon.bs.BasicCloudDialog.ButtonLabel.CANCEL - ) + interaction_style = bcdlg.InteractionStyle.UNKNOWN + button_label_positive = bcdlg.ButtonLabel.OK + button_label_negative = bcdlg.ButtonLabel.CANCEL section = _TextSection( sub_width=sub_width, @@ -1047,9 +1042,7 @@ class InboxWindow(bui.MainWindow): if uiscale is bui.UIScale.SMALL: y -= 36 - # for i, _wrapper in enumerate(response.wrappers): for entry_display in self._entry_displays: - # entry_display = self._entry_displays[i] entry_display_weak = weakref.ref(entry_display) bwidth = 140 bheight = 40 @@ -1089,9 +1082,7 @@ class InboxWindow(bui.MainWindow): buttonrow: list[bui.Widget] = [] have_negative_button = ( entry_display.interaction_style - is ( - bacommon.bs.BasicCloudDialog - ).InteractionStyle.BUTTON_POSITIVE_NEGATIVE + is bcdlg.InteractionStyle.BUTTON_POSITIVE_NEGATIVE ) bpos = ( @@ -1116,10 +1107,10 @@ class InboxWindow(bui.MainWindow): ), color=entry_display.color, textcolor=(0, 1, 0), - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._on_entry_display_press, entry_display_weak, - bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE, + cdlg.Action.BUTTON_PRESS_POSITIVE, ), enable_sound=False, ) @@ -1151,10 +1142,10 @@ class InboxWindow(bui.MainWindow): ), color=(0.85, 0.5, 0.7), textcolor=(1, 0.4, 0.4), - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._on_entry_display_press, entry_display_weak, - (bacommon.bs.CloudDialogAction).BUTTON_PRESS_NEGATIVE, + (cdlg.Action).BUTTON_PRESS_NEGATIVE, ), enable_sound=False, ) diff --git a/dist/ba_data/python/bauiv1lib/ingamemenu.py b/dist/ba_data/python/bauiv1lib/ingamemenu.py index f9fe988..c425ec0 100644 --- a/dist/ba_data/python/bauiv1lib/ingamemenu.py +++ b/dist/ba_data/python/bauiv1lib/ingamemenu.py @@ -177,7 +177,9 @@ class InGameMenuWindow(bui.MainWindow): # Keep updating in a timer in case it gets changed elsewhere. self._change_replay_speed_timer = bui.AppTimer( - 0.25, bui.WeakCall(self._change_replay_speed, 0), repeat=True + 0.25, + bui.WeakCallStrict(self._change_replay_speed, 0), + repeat=True, ) btn = bui.buttonwidget( parent=self._root_widget, @@ -190,7 +192,7 @@ class InGameMenuWindow(bui.MainWindow): size=(b_size, b_size), label='', autoselect=True, - on_activate_call=bui.Call(self._change_replay_speed, -1), + on_activate_call=bui.CallStrict(self._change_replay_speed, -1), ) bui.textwidget( parent=self._root_widget, @@ -213,7 +215,7 @@ class InGameMenuWindow(bui.MainWindow): size=(b_size, b_size), label='', autoselect=True, - on_activate_call=bui.Call(self._change_replay_speed, 1), + on_activate_call=bui.CallStrict(self._change_replay_speed, 1), ) bui.textwidget( parent=self._root_widget, @@ -240,7 +242,7 @@ class InGameMenuWindow(bui.MainWindow): else bui.SpecialChar.PAUSE_BUTTON ), autoselect=True, - on_activate_call=bui.Call(self._pause_or_resume_replay), + on_activate_call=bui.CallStrict(self._pause_or_resume_replay), ) btn = bui.buttonwidget( parent=self._root_widget, @@ -253,7 +255,7 @@ class InGameMenuWindow(bui.MainWindow): size=(b_size, b_size), label='', autoselect=True, - on_activate_call=bui.WeakCall(self._rewind_replay), + on_activate_call=bui.WeakCallStrict(self._rewind_replay), ) bui.textwidget( parent=self._root_widget, @@ -280,7 +282,7 @@ class InGameMenuWindow(bui.MainWindow): size=(b_size, b_size), label='', autoselect=True, - on_activate_call=bui.WeakCall(self._forward_replay), + on_activate_call=bui.WeakCallStrict(self._forward_replay), ) bui.textwidget( parent=self._root_widget, @@ -408,9 +410,11 @@ class InGameMenuWindow(bui.MainWindow): resume = bool(entry.get('resume_on_call', True)) if resume: - call = bui.Call(self._resume_and_call, entry['call']) + call = bui.CallStrict(self._resume_and_call, entry['call']) else: - call = bui.Call(entry['call'], bui.WeakCall(self._resume)) + call = bui.CallStrict( + entry['call'], bui.WeakCallStrict(self._resume) + ) bui.buttonwidget( parent=self._root_widget, diff --git a/dist/ba_data/python/bauiv1lib/inventory.py b/dist/ba_data/python/bauiv1lib/inventory.py index 256f5ae..5a9bcac 100644 --- a/dist/ba_data/python/bauiv1lib/inventory.py +++ b/dist/ba_data/python/bauiv1lib/inventory.py @@ -4,151 +4,405 @@ from __future__ import annotations -from typing import override +import random +from typing import override, TYPE_CHECKING + +from efro.util import asserttype +import bacommon.docui.v1 as dui1 import bauiv1 as bui +from bauiv1lib.docui import DocUIController -class InventoryWindow(bui.MainWindow): - """Shows what you got.""" +if TYPE_CHECKING: + from typing import Any - def __init__( - self, - transition: str | None = 'in_right', - origin_widget: bui.Widget | None = None, - auxiliary_style: bool = True, - ): + from bacommon.docui import DocUIRequest, DocUIResponse - uiscale = bui.app.ui_v1.uiscale - self._width = 1400 if uiscale is bui.UIScale.SMALL else 750 - self._height = ( - 1200 - if uiscale is bui.UIScale.SMALL - else 530 if uiscale is bui.UIScale.MEDIUM else 600 - ) + from bauiv1lib.docui import DocUILocalAction, DocUIWindow - # Do some fancy math to fill all available screen area up to the - # size of our backing container. This lets us fit to the exact - # screen shape at small ui scale. - screensize = bui.get_virtual_screen_size() - scale = ( - 1.55 - if uiscale is bui.UIScale.SMALL - else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0 - ) - # Calc screen size in our local container space and clamp to a - # bit smaller than our container size. - target_height = min(self._height - 100, screensize[1] / scale) +class InventoryUIController(DocUIController): + """DocUI setup for inventory.""" - # To get top/left coords, go to the center of our window and - # offset by half the width/height of our target area. - yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + def __init__(self, player_profiles_only: bool = False) -> None: + self._next_selected_profile: str | None = None + self._player_profiles_only = player_profiles_only - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - toolbar_visibility=( - 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' - ), - toolbar_cancel_button_style=( - 'close' if auxiliary_style else 'back' - ), - scale=scale, - ), - transition=transition, - origin_widget=origin_widget, - # We're affected by screen size only at small ui-scale. - refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, - ) + @override + def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: - bui.textwidget( - parent=self._root_widget, - position=( - self._width * 0.5, - yoffs - (50 if uiscale is bui.UIScale.SMALL else 30), - ), - size=(0, 0), - text=bui.Lstr(resource='inventoryText'), - color=bui.app.ui_v1.title_color, - scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, - maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), - h_align='center', - v_align='center', - ) + response: DocUIResponse - if uiscale is bui.UIScale.SMALL: - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back + # If we only want player profiles, we can skip the whole cloud + # request bit. + if self._player_profiles_only: + response = dui1.Response( + page=dui1.Page( + title='{"r":"inventoryText"}', + title_is_lstr=True, + rows=[], + ) ) else: - btn = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|back', - scale=0.8, - position=(50, yoffs - 50), - size=(50, 50) if auxiliary_style else (60, 55), - extra_touch_border_scale=2.0, - button_type=None if auxiliary_style else 'backSmall', - on_activate_call=self.main_window_back, - autoselect=True, - label=bui.charstr( - bui.SpecialChar.CLOSE - if auxiliary_style - else bui.SpecialChar.BACK - ), - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + # *Most* of our inventory comes from the cloud - we just supply + # profiles ourself so it works offline. + response = self.fulfill_request_cloud(request, 'classicinventory') - if bool(False): - print('WOULD TEST NEW STUFF') + assert isinstance(request, dui1.Request) + assert isinstance(response, dui1.Response) + + if request.path != '/': + return response + + signed_in = ( + bui.app.plus is not None + and bui.app.plus.accounts.primary is not None + ) + + # If anything went wrong, replace the error page they sent us with + # a minimal 'most stuff is only available online' page. + inv_only_signin_t = '{"r":"inventoryOnlyAvailableSignedInText"}' + inv_only_online_t = '{"r":"inventoryOnlyAvailableOnlineText"}' + if response.status is not dui1.ResponseStatus.SUCCESS: + response = dui1.Response( + page=dui1.Page( + title='{"r":"inventoryText"}', + title_is_lstr=True, + rows=[ + dui1.ButtonRow( + center_content=True, + buttons=[ + dui1.Button( + ( + inv_only_signin_t + if not signed_in + else inv_only_online_t + ), + label_is_lstr=True, + texture='white', + size=(600, 100), + color=(1, 1, 1, 0.0), + label_scale=0.7, + label_color=(1, 0.4, 0.4, 0.8), + ) + ], + ), + ], + ), + ) + + for row in response.page.rows: + if ( + isinstance(row, dui1.ButtonRow) + and row.title + and '"r":"store.yourCharactersText"' in row.title + ): + for button in row.buttons: + if not button.decorations: + continue + for decoration in button.decorations: + if isinstance(decoration, dui1.Text): + button.action = dui1.Local( + immediate_local_action='spawn_bot', + immediate_local_action_args={ + 'name': decoration.text + }, + ) + break + + # Now add in our profiles, which we handle locally so it is + # available offline. + response.page.rows = [ + dui1.ButtonRow( + title='{"r":"playerProfilesWindow.titleText"}', + title_is_lstr=True, + subtitle='{"r":"playerProfilesWindow.explanationText"}', + subtitle_is_lstr=True, + button_spacing=15, + buttons=self._get_profile_buttons(), + ), + dui1.ButtonRow( + spacing_top=-15, + spacing_bottom=15, + padding_left=13, + buttons=[ + dui1.Button( + '{"r":"editProfileWindow.titleNewText"}', + dui1.Local( + default_sound=False, + immediate_local_action='new_profile', + ), + icon='plusButton', + icon_scale=1.3, + icon_color=(0.7, 0.6, 0.9, 1), + label_is_lstr=True, + style=dui1.ButtonStyle.MEDIUM, + size=(210, 60), + scale=0.8, + color=(0.6, 0.5, 0.8, 1.0), + label_color=(1, 1, 1, 1), + ), + ], + ), + ] + response.page.rows + + return response + + @override + def local_action(self, action: DocUILocalAction) -> None: + if action.name == 'new_profile': + self._new_profile(action) + elif action.name == 'edit_profile': + self._edit_profile(action) + elif action.name == 'spawn_bot': + self._spawn_bot(action) else: - button_width = 300 - self._player_profiles_button = btn = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|playerprofiles', - position=(self._width * 0.5 - button_width * 0.5, yoffs - 200), - autoselect=True, - size=(button_width, 60), - label=bui.Lstr(resource='playerProfilesWindow.titleText'), - color=(0.55, 0.5, 0.6), - icon=bui.gettexture('cuteSpaz'), - textcolor=(0.75, 0.7, 0.8), - on_activate_call=self._player_profiles_press, + bui.screenmessage( + f'Invalid local-action "{action.name}".', color=(1, 0, 0) ) - # Select this by default. - bui.containerwidget(edit=self._root_widget, selected_child=btn) - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, yoffs - 250), - size=(0, 0), - text=bui.Lstr(resource='moreSoonText'), - scale=0.7, - maxwidth=self._width * 0.9, - h_align='center', - v_align='center', + bui.getsound('error').play() + + @override + def restore_window_shared_state( + self, window: DocUIWindow, state: dict + ) -> None: + """Called when a window shared state is being restored.""" + + if not isinstance(window.request, dui1.Request): + return + + # If desired, set the profile button that will be selected in + # the new window. We do this when coming back from creating a + # new profile/etc. + if ( + window.request.path == '/' + and self._next_selected_profile is not None + ): + state['selection'] = f'$(WIN)|profile.{self._next_selected_profile}' + + # Only do this once (return to normal selection save/restore + # after). + self._next_selected_profile = None + + def _on_profile_save(self, name: str) -> None: + # An editor we launched tells us it saved a profile. + + # Have this one selected when we go back to the listing. + self._next_selected_profile = name + bui.pushcall(self._notify_profiles_changed) + + def _on_profile_delete(self, name: str) -> None: + # An editor we launched tells us it deleted a profile. + + # Ask the inventory list to select/show the profile right before + # the one we're deleting. + profiles = bui.app.config.get('Player Profiles', {}) + items = list(profiles.items()) + items.sort(key=lambda x: asserttype(x[0], str).lower()) + + namelower = name.lower() + + prevname = items[0][0] if items else None + for item in items: + if item[0].lower() < namelower: + prevname = item[0] + else: + break + + if prevname is not None: + self._next_selected_profile = prevname + + self._notify_profiles_changed() + + def _notify_profiles_changed(self) -> None: + import bascenev1 as bs + + # If there's a team-chooser in existence, tell it the profile-list + # has probably changed. + session = bs.get_foreground_host_session() + if session is not None: + session.handlemessage(bs.PlayerProfilesChangedMessage()) + + def _get_profile_buttons(self) -> list[dui1.Button]: + # pylint: disable=too-many-locals + + plus = bui.app.plus + assert plus is not None + classic = bui.app.classic + assert classic is not None + + buttons: list[dui1.Button] = [] + + profiles = bui.app.config.get('Player Profiles', {}) + items = list(profiles.items()) + items.sort(key=lambda x: asserttype(x[0], str).lower()) + + account_name: str | None + if plus.get_v1_account_state() == 'signed_in': + account_name = plus.get_v1_account_display_string() + else: + account_name = None + + spaz_appearances = classic.spaz_appearances + spaz_appearance_default = spaz_appearances['Spaz'] + + for p_name, p_info in items: + if p_name == '__account__' and account_name is None: + continue + color, highlight = classic.get_player_profile_colors(p_name) + tval = ( + account_name + if p_name == '__account__' + else classic.get_player_profile_icon(p_name) + p_name + ) + assert tval is not None + + tcolor: Any = bui.safecolor(color, 0.4) + (1.0,) + assert len(tcolor) == 4 + + appearance = spaz_appearances.get(p_info['character']) + if appearance is None: + appearance = spaz_appearance_default + + buttons.append( + dui1.Button( + texture='white', + size=(145, 175), + action=dui1.Local( + default_sound=False, + immediate_local_action='edit_profile', + immediate_local_action_args={'profile': p_name}, + ), + # color=(0.6, 0.5, 0.7, 1.0), + color=(1, 1, 1, 0.0), + widget_id=f'profile.{p_name}', + decorations=[ + dui1.Image( + appearance.icon_texture, + position=(0, 15), + size=(140, 140), + mask_texture='characterIconMask', + tint_texture=appearance.icon_mask_texture, + tint_color=color, + tint2_color=highlight, + ), + dui1.Text( + tval, + position=(0, -75), + size=(130, 40), + flatness=1.0, + shadow=1.0, + color=tcolor, + ), + ], + ) ) - def _player_profiles_press(self) -> None: + return buttons + + def _new_profile(self, action: DocUILocalAction) -> None: # pylint: disable=cyclic-import - from bauiv1lib.profile.browser import ProfileBrowserWindow + from bauiv1lib.profile.edit import EditProfileWindow - self.main_window_replace( - lambda: ProfileBrowserWindow( - origin_widget=self._player_profiles_button + bui.getsound('swish').play() + + plus = bui.app.plus + assert plus is not None + + # Clamp at 100 profiles (otherwise the server will and that's less + # elegant looking). + profiles = bui.app.config.get('Player Profiles', {}) + if len(profiles) > 100: + bui.screenmessage( + bui.Lstr( + translate=( + 'serverResponses', + 'Max number of profiles reached.', + ) + ), + color=(1, 0, 0), + ) + bui.getsound('error').play() + return + + action.window.main_window_replace( + lambda: EditProfileWindow( + existing_profile=None, + on_profile_save=bui.WeakCallPartial(self._on_profile_save), + on_profile_delete=bui.WeakCallPartial(self._on_profile_delete), ) ) - @override - def get_main_window_state(self) -> bui.MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - return bui.BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - transition=transition, origin_widget=origin_widget + def _edit_profile(self, action: DocUILocalAction) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.profile.edit import EditProfileWindow + + bui.getsound('swish').play() + + profile = action.args.get('profile') + assert isinstance(profile, str) + + # Play a random sound from the character. + classic = bui.app.classic + if classic is not None: + profiles = bui.app.config.get('Player Profiles', {}) + p_info = profiles.get(profile) + if p_info: + char = p_info.get('character', 'Spaz') + appearance = classic.spaz_appearances.get(char) + if appearance: + sounds = ( + appearance.jump_sounds + + appearance.attack_sounds + + appearance.pickup_sounds + ) + if sounds: + bui.getsound(random.choice(sounds)).play() + + action.window.main_window_replace( + lambda: EditProfileWindow( + profile, + origin_widget=action.widget, + on_profile_save=bui.WeakCallPartial(self._on_profile_save), + on_profile_delete=bui.WeakCallPartial(self._on_profile_delete), ) ) - @override - def main_window_should_preserve_selection(self) -> bool: - return True + def _spawn_bot(self, action: DocUILocalAction) -> None: + import bascenev1 as bs + from bascenev1lib.mainmenu import MainMenuActivity + from bascenev1lib.actor.spazbot import DemoSpazBotSet, DemoBot + from bascenev1lib.actor.spazappearance import get_appearances + + name = action.args.get('name') + assert isinstance(name, str) + + activity = bs.get_foreground_host_activity() + if not isinstance(activity, MainMenuActivity) or activity.map is None: + return + bounds = activity.map.get_def_bound_box('map_bounds') + if bounds is None: + return + i = 0 + while i < len(activity.bot_sets): + if activity.bot_sets[i].have_living_bots(): + i += 1 + else: + activity.bot_sets.pop(i) + for appearance in get_appearances(True): + if f'"{appearance}"' in name: + with activity.context: + bot_set = DemoSpazBotSet() + DemoBot.randomize_traits(appearance) + bot_set.spawn_bot( + DemoBot, + ( + (bounds[0] + bounds[3]) / 2 + random.uniform(-7, 7), + bounds[4] - 2, + (bounds[2] + bounds[5]) / 2 + random.uniform(-7, 7), + ), + 0, + ) + activity.bot_sets.append(bot_set) + break diff --git a/dist/ba_data/python/bauiv1lib/kiosk.py b/dist/ba_data/python/bauiv1lib/kiosk.py index 64da3c5..bf3453f 100644 --- a/dist/ba_data/python/bauiv1lib/kiosk.py +++ b/dist/ba_data/python/bauiv1lib/kiosk.py @@ -114,7 +114,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'easy'), + on_activate_call=bui.CallStrict(self._do_game, 'easy'), transition_delay=tdelay, position=(h - b_width * 0.5, b_v), label='', @@ -149,7 +149,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'medium'), + on_activate_call=bui.CallStrict(self._do_game, 'medium'), position=(h - b_width * 0.5, b_v), label='', button_type='square', @@ -184,7 +184,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'hard'), + on_activate_call=bui.CallStrict(self._do_game, 'hard'), transition_delay=tdelay, position=(h - b_width * 0.5, b_v), label='', @@ -239,7 +239,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'ctf'), + on_activate_call=bui.CallStrict(self._do_game, 'ctf'), transition_delay=tdelay, position=(h - b_width * 0.5, b_v), label='', @@ -275,7 +275,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'hockey'), + on_activate_call=bui.CallStrict(self._do_game, 'hockey'), position=(h - b_width * 0.5, b_v), label='', button_type='square', @@ -310,7 +310,7 @@ class KioskWindow(bui.MainWindow): parent=self._root_widget, autoselect=True, size=(b_width, b_height), - on_activate_call=bui.Call(self._do_game, 'epic'), + on_activate_call=bui.CallStrict(self._do_game, 'epic'), transition_delay=tdelay, position=(h - b_width * 0.5, b_v), label='', @@ -361,7 +361,7 @@ class KioskWindow(bui.MainWindow): self._restore_state() self._update() self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) @override @@ -468,9 +468,11 @@ class KioskWindow(bui.MainWindow): appconfig['Free-for-All Playlist Selection'] = 'Just Epic Elim' bui.fade_screen( False, - endcall=bui.Call( + endcall=bui.CallStrict( bui.pushcall, - bui.Call(bs.new_host_session, bs.FreeForAllSession), + bui.CallStrict( + bs.new_host_session, bs.FreeForAllSession + ), ), ) else: @@ -507,9 +509,9 @@ class KioskWindow(bui.MainWindow): ) bui.fade_screen( False, - endcall=bui.Call( + endcall=bui.CallStrict( bui.pushcall, - bui.Call(bs.new_host_session, bs.DualTeamSession), + bui.CallStrict(bs.new_host_session, bs.DualTeamSession), ), ) bui.containerwidget(edit=self._root_widget, transition='out_left') diff --git a/dist/ba_data/python/bauiv1lib/league/presidency.py b/dist/ba_data/python/bauiv1lib/league/presidency.py new file mode 100644 index 0000000..3d35b76 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/league/presidency.py @@ -0,0 +1,40 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Shiny new doc-ui based store.""" + +from __future__ import annotations + +from typing import override, TYPE_CHECKING + +from bauiv1lib.docui import DocUIController + +import bauiv1 as bui + +if TYPE_CHECKING: + from bacommon.docui import DocUIRequest, DocUIResponse + + from bauiv1lib.docui import DocUILocalAction + + +class LeaguePresidencyUIController(DocUIController): + """DocUI setup for store.""" + + @override + def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: + return self.fulfill_request_cloud(request, 'classicleaguepresidency') + + @override + def local_action(self, action: DocUILocalAction) -> None: + if action.name == 'get_tokens': + self._get_tokens(action) + else: + bui.screenmessage( + f'Invalid local-action "{action.name}".', color=(1, 0, 0) + ) + bui.getsound('error').play() + + def _get_tokens(self, action: DocUILocalAction) -> None: + from bauiv1lib.gettokens import show_get_tokens_window + + bui.getsound('swish').play() + show_get_tokens_window(origin_widget=bui.existing(action.widget)) diff --git a/dist/ba_data/python/bauiv1lib/league/rankwindow.py b/dist/ba_data/python/bauiv1lib/league/rankwindow.py index f145abb..f27bd4f 100644 --- a/dist/ba_data/python/bauiv1lib/league/rankwindow.py +++ b/dist/ba_data/python/bauiv1lib/league/rankwindow.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """UI related to league rank.""" + # pylint: disable=too-many-lines from __future__ import annotations @@ -9,9 +10,10 @@ import copy import logging from typing import TYPE_CHECKING, override +import bacommon.classic +import bauiv1 as bui from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top from bauiv1lib.popup import PopupMenu -import bauiv1 as bui if TYPE_CHECKING: from typing import Any @@ -30,6 +32,8 @@ class LeagueRankWindow(bui.MainWindow): plus = bui.app.plus assert plus is not None + self._uiopenstate = bui.UIOpenState('classicleaguerank') + bui.set_analytics_screen('League Rank Window') self._league_rank_data: dict[str, Any] | None = None @@ -216,10 +220,34 @@ class LeagueRankWindow(bui.MainWindow): self._update_for_league_rank_data(info) self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update(show=info is None) + def _on_p_button_info_response( + self, + response: ( + bacommon.classic.GetClassicLeaguePresidentButtonInfoResponse + | Exception + ), + ) -> None: + + # If our window has died, no-op. + if not self._root_widget: + return + + if isinstance(response, Exception) or response.name is None: + bui.textwidget( + edit=self._president_name, color=(0.6, 0.6, 1, 0.2), text='-' + ) + return + + bui.textwidget( + edit=self._president_name, + color=(0.6, 0.6, 1, 0.9), + text=response.name, + ) + @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -381,8 +409,24 @@ class LeagueRankWindow(bui.MainWindow): self._doing_power_ranking_query = True plus.power_ranking_query( season=self._requested_season, - callback=bui.WeakCall(self._on_power_ranking_query_response), + callback=bui.WeakCallPartial( + self._on_power_ranking_query_response + ), ) + # Also kick off a query to v2 for latest league-president + # info. + if plus.accounts.primary is not None: + with plus.accounts.primary: + plus.cloud.send_message_cb( + ( + bacommon.classic + ).GetClassicLeaguePresidentButtonInfoMessage( + season=self._requested_season + ), + on_response=bui.WeakCallPartial( + self._on_p_button_info_response + ), + ) def _refresh(self) -> None: # pylint: disable=too-many-statements @@ -413,7 +457,7 @@ class LeagueRankWindow(bui.MainWindow): v2 = v - 60 worth_color = (0.6, 0.6, 0.65) tally_color = (0.5, 0.6, 0.8) - spc = 43 + spc = 23 h_offs_tally = 150 tally_maxwidth = 120 @@ -440,7 +484,7 @@ class LeagueRankWindow(bui.MainWindow): size=(200, 80), icon=bui.gettexture('achievementsIcon'), autoselect=True, - on_activate_call=bui.WeakCall(self._on_achievements_press), + on_activate_call=bui.WeakCallStrict(self._on_achievements_press), up_widget=self._back_button, left_widget=self._back_button, color=(0.5, 0.5, 0.6), @@ -471,7 +515,7 @@ class LeagueRankWindow(bui.MainWindow): size=(200, 80), icon=bui.gettexture('medalSilver'), autoselect=True, - on_activate_call=bui.WeakCall(self._on_trophies_press), + on_activate_call=bui.WeakCallStrict(self._on_trophies_press), left_widget=self._back_button, color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), @@ -518,7 +562,9 @@ class LeagueRankWindow(bui.MainWindow): icon_color=(0.5, 0, 0.5), label=bui.Lstr(resource='coopSelectWindow.activityText'), autoselect=True, - on_activate_call=bui.WeakCall(self._on_activity_mult_press), + on_activate_call=bui.WeakCallStrict( + self._on_activity_mult_press + ), left_widget=self._back_button, color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), @@ -550,7 +596,9 @@ class LeagueRankWindow(bui.MainWindow): icon_color=(0.3, 0, 0.3), label=bui.Lstr(resource='league.upToDateBonusText'), autoselect=True, - on_activate_call=bui.WeakCall(self._on_up_to_date_bonus_press), + on_activate_call=bui.WeakCallStrict( + self._on_up_to_date_bonus_press + ), left_widget=self._back_button, color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), @@ -599,6 +647,64 @@ class LeagueRankWindow(bui.MainWindow): maxwidth=tally_maxwidth, ) + self._president_button = bui.buttonwidget( + parent=w_parent, + id=f'{self.main_window_id_prefix}|president', + label='', + position=(self._xoffs + h2 - 60, v2 - 100), + color=(0.7, 0.55, 0.9), + texture=bui.gettexture('buttonSquareWide'), + opacity=0.3, + size=(200, 80), + autoselect=True, + on_activate_call=bui.WeakCallStrict(self._on_president_press), + ) + self._president_label = bui.textwidget( + parent=w_parent, + text=bui.Lstr(resource='league.leaguePresidentText'), + flatness=1.0, + shadow=0.0, + color=(0.6, 0.6, 1, 0.7), + draw_controller=self._president_button, + scale=0.5, + h_align='center', + v_align='center', + maxwidth=140, + position=(self._xoffs + h2 - 60 + 100, v2 - 100 + 59), + size=(0, 0), + ) + self._president_name = bui.textwidget( + parent=w_parent, + text='-', + draw_controller=self._president_button, + color=(0.6, 0.6, 1, 0.2), + flatness=1.0, + shadow=0.0, + h_align='center', + v_align='center', + maxwidth=120, + position=(self._xoffs + h2 - 60 + 100, v2 - 100 + 34), + size=(0, 0), + ) + self._president_star1 = bui.imagewidget( + parent=w_parent, + draw_controller=self._president_button, + texture=bui.gettexture('star'), + color=(0.7, 0.55, 0.9), + opacity=0.2, + position=(self._xoffs + h2 - 60 + 5, v2 - 100 + 17), + size=(32, 32), + ) + self._president_star1 = bui.imagewidget( + parent=w_parent, + draw_controller=self._president_button, + texture=bui.gettexture('star'), + color=(0.7, 0.55, 0.9), + opacity=0.2, + position=(self._xoffs + h2 - 60 + 200 - 5 - 32, v2 - 100 + 17), + size=(32, 32), + ) + self._season_show_text = bui.textwidget( parent=w_parent, position=(self._xoffs + 390 - 15, v - 20), @@ -748,7 +854,44 @@ class LeagueRankWindow(bui.MainWindow): textcolor=(0.7, 0.7, 0.8), size=(230, 60), autoselect=True, - on_activate_call=bui.WeakCall(self._on_more_press), + on_activate_call=bui.WeakCallStrict(self._on_more_press), + ) + + def _on_president_press(self) -> None: + import bacommon.docui.v1 as dui1 + + from bauiv1lib.league.presidency import LeaguePresidencyUIController + from bauiv1lib.connectivity import wait_for_connectivity + + # No-op if we're not in control. + if not self.main_window_has_control(): + return + + plus = bui.app.plus + assert plus is not None + + # We should be signed in at this point, but let's be sure. + if plus.accounts.primary is None: + bui.screenmessage( + bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + # Wait for connectivity if need be, then bring up a cloud based + # doc-ui window for showing/futzing-with league president stuff. + wait_for_connectivity( + on_connected=lambda: self.main_window_replace( + bui.CallStrict( + LeaguePresidencyUIController().create_window, + dui1.Request('/', args={'season': self._season}), + origin_widget=self._president_button, + auxiliary_style=False, + ), + extra_type_id=( + LeaguePresidencyUIController + ).get_window_extra_type_id(), + ) ) def _on_more_press(self) -> None: @@ -893,7 +1036,9 @@ class LeagueRankWindow(bui.MainWindow): width=150, button_size=(200, 50), choices=season_choices, - on_value_change_call=bui.WeakCall(self._on_season_change), + on_value_change_call=bui.WeakCallPartial( + self._on_season_change + ), choices_display=season_choices_display, current_choice=self._season, ) @@ -1142,7 +1287,7 @@ class LeagueRankWindow(bui.MainWindow): widget.delete() self._power_ranking_score_widgets = [] - scores = data['scores'] if data is not None else [] + scores: list = data['scores'] if data is not None else [] tally_color = (0.5, 0.6, 0.8) w_parent = self._subcontainer v2 = self._power_ranking_score_v @@ -1202,7 +1347,7 @@ class LeagueRankWindow(bui.MainWindow): self._power_ranking_score_widgets.append(txt) bui.textwidget( edit=txt, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._show_account_info, score[4], txt ), ) diff --git a/dist/ba_data/python/bauiv1lib/mainmenu.py b/dist/ba_data/python/bauiv1lib/mainmenu.py index f0e1bb3..d4b7af0 100644 --- a/dist/ba_data/python/bauiv1lib/mainmenu.py +++ b/dist/ba_data/python/bauiv1lib/mainmenu.py @@ -75,9 +75,7 @@ class MainMenuWindow(bui.MainWindow): create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget, - # id_prefix=id_prefix, ), - # restore_selection=True, ) @override @@ -91,13 +89,12 @@ class MainMenuWindow(bui.MainWindow): import bauiv1lib.getremote as _unused import bauiv1lib.confirm as _unused2 import bauiv1lib.account.settings as _unused5 - import bauiv1lib.store.browser as _unused6 - import bauiv1lib.credits as _unused7 - import bauiv1lib.help as _unused8 - import bauiv1lib.settings.allsettings as _unused9 - import bauiv1lib.gather as _unused10 - import bauiv1lib.watch as _unused11 - import bauiv1lib.play as _unused12 + import bauiv1lib.credits as _unused6 + import bauiv1lib.help as _unused7 + import bauiv1lib.settings.allsettings as _unused8 + import bauiv1lib.gather as _unused9 + import bauiv1lib.watch as _unused10 + import bauiv1lib.play as _unused11 def _show_remote_app_info_on_first_launch(self) -> None: app = bui.app @@ -256,7 +253,7 @@ class MainMenuWindow(bui.MainWindow): text=( f'{app.env.engine_version}' f' build {app.env.engine_build_number}.' - f' Copyright 2011-2025 Eric Froemling.' + f' Copyright 2011-2026 Eric Froemling.' ), h_align='center', v_align='center', diff --git a/dist/ba_data/python/bauiv1lib/party.py b/dist/ba_data/python/bauiv1lib/party.py index d1628f7..dbe1453 100644 --- a/dist/ba_data/python/bauiv1lib/party.py +++ b/dist/ba_data/python/bauiv1lib/party.py @@ -21,11 +21,9 @@ if TYPE_CHECKING: class PartyWindow(bui.Window): """Party list/chat window.""" - def __del__(self) -> None: - bui.set_party_window_open(False) - def __init__(self, origin: Sequence[float] = (0, 0)): - bui.set_party_window_open(True) + + self._uiopenstate = bui.UIOpenState('classicparty') self._r = 'partyWindow' self._popup_type: str | None = None self._popup_party_member_client_id: int | None = None @@ -91,7 +89,7 @@ class PartyWindow(bui.Window): label='...', autoselect=True, button_type='square', - on_activate_call=bui.WeakCall(self._on_menu_button_press), + on_activate_call=bui.WeakCallStrict(self._on_menu_button_press), color=(0.55, 0.73, 0.25), iconscale=1.2, ) @@ -213,7 +211,7 @@ class PartyWindow(bui.Window): self._name_widgets: list[bui.Widget] = [] self._roster: list[dict[str, Any]] | None = None self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() @@ -234,7 +232,7 @@ class PartyWindow(bui.Window): maxwidth=self._scroll_width * 0.94, shadow=0.3, flatness=1.0, - on_activate_call=bui.Call(self._copy_msg, msg), + on_activate_call=bui.CallStrict(self._copy_msg, msg), selectable=True, ) @@ -428,7 +426,7 @@ class PartyWindow(bui.Window): # client_id is more readily available though). bui.textwidget( edit=widget, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._on_party_member_press, self._roster[index]['client_id'], is_host, diff --git a/dist/ba_data/python/bauiv1lib/partyqueue.py b/dist/ba_data/python/bauiv1lib/partyqueue.py index 202b138..398c272 100644 --- a/dist/ba_data/python/bauiv1lib/partyqueue.py +++ b/dist/ba_data/python/bauiv1lib/partyqueue.py @@ -69,7 +69,7 @@ class PartyQueueWindow(bui.Window): ) bui.buttonwidget( edit=self._body_image, - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( parent.on_account_press, account_id, self._body_image ), ) @@ -131,7 +131,7 @@ class PartyQueueWindow(bui.Window): widget.delete() bui.pushcall( - bui.Call( + bui.CallStrict( kill_widgets, [ self._body_image, @@ -318,7 +318,7 @@ class PartyQueueWindow(bui.Window): # Update at roughly 30fps. self._update_timer = bui.AppTimer( - 0.033, bui.WeakCall(self.update), repeat=True + 0.033, bui.WeakCallStrict(self.update), repeat=True ) self.update() @@ -591,7 +591,7 @@ class PartyQueueWindow(bui.Window): 't': self._boost_tickets, 'q': self._queue_id, }, - callback=bui.WeakCall(self.on_update_response), + callback=bui.WeakCallPartial(self.on_update_response), ) # Let's not run these immediately (since they may be rapid-fire, # just bucket them until the next tick). @@ -654,7 +654,7 @@ class PartyQueueWindow(bui.Window): self._last_transaction_time = current_time plus.add_v1_account_transaction( {'type': 'PARTY_QUEUE_QUERY', 'q': self._queue_id}, - callback=bui.WeakCall(self.on_update_response), + callback=bui.WeakCallPartial(self.on_update_response), ) plus.run_v1_account_transactions() diff --git a/dist/ba_data/python/bauiv1lib/play.py b/dist/ba_data/python/bauiv1lib/play.py index 5ad3c49..dd509e0 100644 --- a/dist/ba_data/python/bauiv1lib/play.py +++ b/dist/ba_data/python/bauiv1lib/play.py @@ -603,7 +603,7 @@ class PlayWindow(bui.MainWindow): assert plus is not None if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() + show_sign_in_prompt(origin_widget=self._coop_button) return self.main_window_replace( diff --git a/dist/ba_data/python/bauiv1lib/playlist/addgame.py b/dist/ba_data/python/bauiv1lib/playlist/addgame.py index b2457a0..199fab3 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/addgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/addgame.py @@ -58,7 +58,11 @@ class PlaylistAddGameWindow(bui.MainWindow): root_widget=bui.containerwidget( size=(self._width, self._height), scale=scale, - toolbar_visibility='menu_minimal', + toolbar_visibility=( + 'menu_minimal' + if uiscale is bui.UIScale.SMALL + else 'menu_full' + ), ), transition=transition, origin_widget=origin_widget, @@ -252,7 +256,9 @@ class PlaylistAddGameWindow(bui.MainWindow): v_align='center', color=(0.8, 0.8, 0.8, 1.0), maxwidth=self._scroll_width * 0.8, - on_select_call=bui.Call(self._set_selected_game_type, gametype), + on_select_call=bui.CallStrict( + self._set_selected_game_type, gametype + ), always_highlight=True, selectable=True, on_activate_call=_doit, @@ -277,25 +283,35 @@ class PlaylistAddGameWindow(bui.MainWindow): ) def _on_get_more_games_press(self) -> None: - from bauiv1lib.account.signin import show_sign_in_prompt - from bauiv1lib.store.browser import StoreBrowserWindow + import bacommon.docui.v1 as dui1 - # No-op if we're not in control. - if not self.main_window_has_control(): - return + from bauiv1lib.docui import DocUIWindow + from bauiv1lib.account.signin import show_sign_in_prompt + from bauiv1lib.store import StoreUIController + from bauiv1lib.connectivity import wait_for_connectivity plus = bui.app.plus assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': + if plus.accounts.primary is None: show_sign_in_prompt() return - self.main_window_replace( - lambda: StoreBrowserWindow( - show_tab=StoreBrowserWindow.TabID.MINIGAMES, - origin_widget=self._get_more_games_button, - minimal_toolbars=True, + # Playlist editing happens in the regular non-auxiliary window + # stack so we can just pop up the regular auxiliary-mode store + # and it'll do the right thing and take us back to our editing + # when we close it. + wait_for_connectivity( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( + win_type=DocUIWindow, + win_create_call=bui.CallStrict( + StoreUIController().create_window, + dui1.Request('/'), + origin_widget=self._get_more_games_button, + uiopenstateid='classicstore', + ), + win_extra_type_id=( + StoreUIController.get_window_extra_type_id() + ), ) ) diff --git a/dist/ba_data/python/bauiv1lib/playlist/browser.py b/dist/ba_data/python/bauiv1lib/playlist/browser.py index 77b30d5..d296867 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/browser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/browser.py @@ -190,7 +190,7 @@ class PlaylistBrowserWindow(bui.MainWindow): # refresh). self._update() self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) @override @@ -527,10 +527,12 @@ class PlaylistBrowserWindow(bui.MainWindow): ) bui.buttonwidget( edit=btn, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._on_playlist_press, btn, name ), - on_select_call=bui.Call(self._on_playlist_select, name), + on_select_call=bui.CallStrict( + self._on_playlist_select, name + ), ) # Top row biases things up more to show header above it. diff --git a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py index a8b2476..1ba27a7 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py @@ -16,8 +16,6 @@ if TYPE_CHECKING: import bascenev1 as bs -REQUIRE_PRO = False - class PlaylistCustomizeBrowserWindow(bui.MainWindow): """Window for viewing a playlist.""" @@ -275,7 +273,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): # Keep our lock images up to date/etc. self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() @@ -310,12 +308,8 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): cfg.commit() def _update(self) -> None: - assert bui.app.classic is not None - have = bui.app.classic.accounts.have_pro_options() for lock in self._lock_images: - bui.imagewidget( - edit=lock, opacity=0.0 if (have or not REQUIRE_PRO) else 1.0 - ) + bui.imagewidget(edit=lock, opacity=0.0) # No more pro req. def _select(self, name: str, index: int) -> None: self._selected_playlist_name = name @@ -365,8 +359,8 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): else (0.85, 0.85, 0.85, 1) ), always_highlight=True, - on_select_call=bui.Call(self._select, pname, index), - on_activate_call=bui.Call(self._edit_button.activate), + on_select_call=bui.CallStrict(self._select, pname, index), + on_activate_call=bui.CallStrict(self._edit_button.activate), selectable=True, ) # We don't give these widgets ids because we handle @@ -437,17 +431,11 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): def _new_playlist(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.playlist.editcontroller import PlaylistEditController - from bauiv1lib.purchase import PurchaseWindow # No-op if we're not in control. if not self.main_window_has_control(): return - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): - PurchaseWindow(items=['pro']) - return - # Clamp at our max playlist number. if len(bui.app.config[self._config_name_full]) > self._max_playlists: bui.screenmessage( @@ -471,12 +459,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): def _edit_playlist(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.playlist.editcontroller import PlaylistEditController - from bauiv1lib.purchase import PurchaseWindow - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): - PurchaseWindow(items=['pro']) - return if self._selected_playlist_name is None: return if self._selected_playlist_name == '__default__': @@ -530,7 +513,9 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): share.SharePlaylistImportWindow( origin_widget=self._import_button, - on_success_callback=bui.WeakCall(self._on_playlist_import_success), + on_success_callback=bui.WeakCallStrict( + self._on_playlist_import_success + ), ) def _on_playlist_import_success(self) -> None: @@ -550,17 +535,9 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): share.SharePlaylistResultsWindow(name, response) def _share_playlist(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow - plus = bui.app.plus assert plus is not None - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): - PurchaseWindow(items=['pro']) - return - # Gotta be signed in for this to work. if plus.get_v1_account_state() != 'signed_in': bui.screenmessage( @@ -586,7 +563,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): 'playlistType': self._pvars.config_name, 'playlistName': self._selected_playlist_name, }, - callback=bui.WeakCall( + callback=bui.WeakCallPartial( self._on_share_playlist_response, self._selected_playlist_name ), ) @@ -594,15 +571,8 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): bui.screenmessage(bui.Lstr(resource='sharingText')) def _delete_playlist(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.confirm import ConfirmWindow - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): - PurchaseWindow(items=['pro']) - return - if self._selected_playlist_name is None: return if self._selected_playlist_name == '__default__': @@ -631,17 +601,9 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): ) def _duplicate_playlist(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow - plus = bui.app.plus assert plus is not None - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): - PurchaseWindow(items=['pro']) - return if self._selected_playlist_name is None: return plst: list[dict[str, Any]] | None diff --git a/dist/ba_data/python/bauiv1lib/playlist/edit.py b/dist/ba_data/python/bauiv1lib/playlist/edit.py index 88735e4..7c8c95b 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/edit.py +++ b/dist/ba_data/python/bauiv1lib/playlist/edit.py @@ -49,8 +49,10 @@ class PlaylistEditWindow(bui.MainWindow): if uiscale is bui.UIScale.SMALL else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), - stack_offset=( - (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) + toolbar_visibility=( + 'menu_minimal_no_back' + if uiscale is bui.UIScale.SMALL + else 'menu_full' ), ), transition=transition, @@ -156,7 +158,7 @@ class PlaylistEditWindow(bui.MainWindow): position=(h, v), size=(110, 61.0 * scl), on_activate_call=self._add, - on_select_call=bui.Call(self._set_ui_selection, 'add_button'), + on_select_call=bui.CallStrict(self._set_ui_selection, 'add_button'), autoselect=True, button_type='square', color=b_color, @@ -172,7 +174,7 @@ class PlaylistEditWindow(bui.MainWindow): position=(h, v), size=(110, 61.0 * scl), on_activate_call=self._edit, - on_select_call=bui.Call(self._set_ui_selection, 'editButton'), + on_select_call=bui.CallStrict(self._set_ui_selection, 'editButton'), autoselect=True, button_type='square', color=b_color, @@ -230,7 +232,7 @@ class PlaylistEditWindow(bui.MainWindow): parent=self._root_widget, position=(160 + x_inset, v - scroll_height), highlight=False, - on_select_call=bui.Call(self._set_ui_selection, 'gameList'), + on_select_call=bui.CallStrict(self._set_ui_selection, 'gameList'), size=(self._scroll_width, (scroll_height - 15)), border_opacity=0.4, ) @@ -415,7 +417,7 @@ class PlaylistEditWindow(bui.MainWindow): txtw = bui.textwidget( parent=self._columnwidget, size=(self._width - 80, 30), - on_select_call=bui.Call(self._select, index), + on_select_call=bui.CallStrict(self._select, index), always_highlight=True, color=(0.8, 0.8, 0.8, 1.0), padding=0, diff --git a/dist/ba_data/python/bauiv1lib/playlist/editgame.py b/dist/ba_data/python/bauiv1lib/playlist/editgame.py index f83b2a4..9fc6869 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/editgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/editgame.py @@ -132,8 +132,10 @@ class PlaylistEditGameWindow(bui.MainWindow): if uiscale is bui.UIScale.SMALL else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ), - stack_offset=( - (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) + toolbar_visibility=( + 'menu_minimal_no_back' + if uiscale is bui.UIScale.SMALL + else 'menu_full' ), ), transition=transition, @@ -153,7 +155,7 @@ class PlaylistEditGameWindow(bui.MainWindow): autoselect=True, scale=1.0 if is_add else 0.75, text_scale=1.3, - on_activate_call=bui.Call(self._cancel), + on_activate_call=bui.CallStrict(self._cancel), ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) @@ -248,7 +250,7 @@ class PlaylistEditGameWindow(bui.MainWindow): parent=self._subcontainer, size=(140, 60), position=(h + 448, v - 72), - on_activate_call=bui.Call(self._select_map), + on_activate_call=bui.CallStrict(self._select_map), scale=0.7, label=bui.Lstr(resource='mapSelectText'), ) @@ -359,7 +361,7 @@ class PlaylistEditGameWindow(bui.MainWindow): size=(28, 28), label='<', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._choice_inc, setting.name, txt, setting, -1 ), repeat=True, @@ -370,7 +372,7 @@ class PlaylistEditGameWindow(bui.MainWindow): size=(28, 28), label='>', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._choice_inc, setting.name, txt, setting, 1 ), repeat=True, @@ -410,7 +412,7 @@ class PlaylistEditGameWindow(bui.MainWindow): size=(28, 28), label='-', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._inc, txt, min_value, @@ -427,7 +429,7 @@ class PlaylistEditGameWindow(bui.MainWindow): size=(28, 28), label='+', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._inc, txt, min_value, @@ -476,7 +478,7 @@ class PlaylistEditGameWindow(bui.MainWindow): autoselect=True, textcolor=(0.8, 0.8, 0.8), value=value, - on_value_change_call=bui.Call( + on_value_change_call=bui.CallPartial( self._check_value_change, setting.name, txt ), ) @@ -503,7 +505,9 @@ class PlaylistEditGameWindow(bui.MainWindow): 'Error wiring up game-settings-select widget column.' ) - bui.buttonwidget(edit=add_button, on_activate_call=bui.Call(self._add)) + bui.buttonwidget( + edit=add_button, on_activate_call=bui.CallStrict(self._add) + ) bui.containerwidget( edit=self._root_widget, selected_child=add_button, diff --git a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py index ad60a2a..8895f6c 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py +++ b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py @@ -67,8 +67,10 @@ class PlaylistMapSelectWindow(bui.MainWindow): if uiscale is bui.UIScale.SMALL else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), - stack_offset=( - (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) + toolbar_visibility=( + 'menu_minimal_no_back' + if uiscale is bui.UIScale.SMALL + else 'menu_full' ), ), transition=transition, @@ -230,7 +232,7 @@ class PlaylistMapSelectWindow(bui.MainWindow): mesh_transparent=mesh_transparent, label='', color=(1, 1, 1), - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._select_with_delay, self._maps[index][0] ), position=pos, @@ -288,8 +290,12 @@ class PlaylistMapSelectWindow(bui.MainWindow): ) def _on_store_press(self) -> None: + import bacommon.docui.v1 as dui1 + + from bauiv1lib.docui import DocUIWindow + from bauiv1lib.connectivity import wait_for_connectivity + from bauiv1lib.store import StoreUIController from bauiv1lib.account.signin import show_sign_in_prompt - from bauiv1lib.store.browser import StoreBrowserWindow # No-op if we're not in control. if not self.main_window_has_control(): @@ -298,17 +304,28 @@ class PlaylistMapSelectWindow(bui.MainWindow): plus = bui.app.plus assert plus is not None - if plus.get_v1_account_state() != 'signed_in': + if plus.accounts.primary is None: show_sign_in_prompt() return self._selected_get_more_maps = True - self.main_window_replace( - lambda: StoreBrowserWindow( - show_tab=StoreBrowserWindow.TabID.MAPS, - origin_widget=self._get_more_maps_button, - minimal_toolbars=True, + # Playlist editing happens in the regular non-auxiliary window + # stack so we can just pop up the regular auxiliary-mode store + # and it'll do the right thing and take us back to our editing + # when we close it. + wait_for_connectivity( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( + win_type=DocUIWindow, + win_create_call=bui.CallStrict( + StoreUIController().create_window, + dui1.Request('/'), + origin_widget=self._get_more_maps_button, + uiopenstateid='classicstore', + ), + win_extra_type_id=( + StoreUIController.get_window_extra_type_id() + ), ) ) @@ -324,4 +341,4 @@ class PlaylistMapSelectWindow(bui.MainWindow): def _select_with_delay(self, map_name: str) -> None: bui.lock_all_input() bui.apptimer(0.1, bui.unlock_all_input) - bui.apptimer(0.1, bui.WeakCall(self._select, map_name)) + bui.apptimer(0.1, bui.WeakCallStrict(self._select, map_name)) diff --git a/dist/ba_data/python/bauiv1lib/playlist/share.py b/dist/ba_data/python/bauiv1lib/playlist/share.py index 9e429cf..03fc597 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/share.py +++ b/dist/ba_data/python/bauiv1lib/playlist/share.py @@ -67,7 +67,7 @@ class SharePlaylistImportWindow(SendInfoWindowLegacyModal): 'expire_time': time.time() + 5, 'code': bui.textwidget(query=self._text_field), }, - callback=bui.WeakCall(self._on_import_response), + callback=bui.WeakCallPartial(self._on_import_response), ) plus.run_v1_account_transactions() bui.screenmessage(bui.Lstr(resource='importingText')) diff --git a/dist/ba_data/python/bauiv1lib/playoptions.py b/dist/ba_data/python/bauiv1lib/playoptions.py index 70fcae0..d333c0d 100644 --- a/dist/ba_data/python/bauiv1lib/playoptions.py +++ b/dist/ba_data/python/bauiv1lib/playoptions.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, override +from bacommon.analytics import ClassicAnalyticsEvent import bascenev1 as bs import bauiv1 as bui @@ -17,8 +18,6 @@ if TYPE_CHECKING: from bauiv1lib.play import PlaylistSelectContext -REQUIRE_PRO = False - class PlayOptionsWindow(PopupWindow): """A popup window for configuring play options.""" @@ -240,7 +239,7 @@ class PlayOptionsWindow(PopupWindow): position=(h, v), texture=bui.gettexture(tex_name if owned else 'empty'), mesh_opaque=mesh_opaque if owned else None, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( bui.screenmessage, desc, desc_color ), label='', @@ -316,7 +315,9 @@ class PlayOptionsWindow(PopupWindow): parent=self.root_widget, position=(100, 195 + y_offs), size=(290, 35), - on_activate_call=bui.WeakCall(self._custom_colors_names_press), + on_activate_call=bui.WeakCallStrict( + self._custom_colors_names_press + ), autoselect=True, textcolor=(0.8, 0.8, 0.8), label=bui.Lstr(resource='teamNamesColorText'), @@ -325,16 +326,6 @@ class PlayOptionsWindow(PopupWindow): edit=self._custom_colors_names_button, allow_preserve_selection=False, ) - - assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): - bui.imagewidget( - parent=self.root_widget, - size=(30, 30), - position=(95, 202 + y_offs), - texture=bui.gettexture('lock'), - draw_controller=self._custom_colors_names_button, - ) else: self._custom_colors_names_button = None @@ -439,26 +430,17 @@ class PlayOptionsWindow(PopupWindow): # Update now and once per second. self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() def _custom_colors_names_press(self) -> None: - from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.teamnamescolors import TeamNamesColorsWindow - from bauiv1lib.purchase import PurchaseWindow plus = bui.app.plus assert plus is not None assert bui.app.classic is not None - if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - else: - PurchaseWindow(items=['pro']) - self._transition_out() - return assert self._custom_colors_names_button TeamNamesColorsWindow( scale_origin=( @@ -544,6 +526,21 @@ class PlayOptionsWindow(PopupWindow): if bs.app.classic is not None: bs.app.classic.save_ui_state() + # Log analytics for when teams/ffa sessions are started from the + # UI. + if self._sessiontype is bs.FreeForAllSession: + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.START_FFA_SESSION + ) + ) + elif self._sessiontype is bs.DualTeamSession: + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.START_TEAMS_SESSION + ) + ) + try: bs.new_host_session(self._sessiontype) except Exception: diff --git a/dist/ba_data/python/bauiv1lib/popup.py b/dist/ba_data/python/bauiv1lib/popup.py index dfc87d5..b1f5774 100644 --- a/dist/ba_data/python/bauiv1lib/popup.py +++ b/dist/ba_data/python/bauiv1lib/popup.py @@ -234,7 +234,7 @@ class PopupMenuWindow(PopupWindow): wdg = bui.textwidget( parent=self._columnwidget, size=(self._width - 40, 28), - on_select_call=bui.Call(self._select, index), + on_select_call=bui.CallStrict(self._select, index), click_activate=True, color=( (0.5, 0.5, 0.5, 0.5) @@ -277,7 +277,7 @@ class PopupMenuWindow(PopupWindow): if delegate is not None: # Call this in a timer so it doesn't interfere with us killing # our widgets and whatnot. - call = bui.Call( + call = bui.CallStrict( delegate.popup_menu_selected_choice, self, self._current_choice ) bui.apptimer(0, call) diff --git a/dist/ba_data/python/bauiv1lib/profile/browser.py b/dist/ba_data/python/bauiv1lib/profile/browser.py deleted file mode 100644 index 18a49f7..0000000 --- a/dist/ba_data/python/bauiv1lib/profile/browser.py +++ /dev/null @@ -1,476 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality related to browsing player profiles.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, override - -import bauiv1 as bui -import bascenev1 as bs - -if TYPE_CHECKING: - from typing import Any, ClassVar - - -class ProfileBrowserWindow(bui.MainWindow): - """Window for browsing player profiles.""" - - # Keep track of this at the class level to share between instances. - selected_profile: ClassVar[str | None] = None - - def __init__( - self, - transition: str | None = 'in_right', - selected_profile: str | None = None, - origin_widget: bui.Widget | None = None, - minimal_toolbar: bool = False, - ): - self._minimal_toolbar = minimal_toolbar - back_label = bui.Lstr(resource='backText') - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - self._width = 800.0 if uiscale is bui.UIScale.SMALL else 600.0 - x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 - self._height = ( - 360.0 - if uiscale is bui.UIScale.SMALL - else 385.0 if uiscale is bui.UIScale.MEDIUM else 410.0 - ) - - # Need to handle out-transitions ourself for modal mode. - if origin_widget is not None: - self._transition_out = 'out_scale' - else: - self._transition_out = 'out_right' - - self._r = 'playerProfilesWindow' - - # Ensure we've got an account-profile in cases where we're signed in. - assert bui.app.classic is not None - bui.app.classic.accounts.ensure_have_account_player_profile() - - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - toolbar_visibility=( - 'menu_minimal' - if (uiscale is bui.UIScale.SMALL or minimal_toolbar) - else 'menu_full' - ), - scale=( - 2.5 - if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -14) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ), - transition=transition, - origin_widget=origin_widget, - ) - - if bui.app.ui_v1.uiscale is bui.UIScale.SMALL: - self._back_button = bui.get_special_widget('back_button') - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) - else: - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|back', - position=(40 + x_inset, self._height - 59), - size=(120, 60), - scale=0.8, - label=back_label, - button_type='back', - autoselect=True, - on_activate_call=self.main_window_back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 36), - size=(0, 0), - text=bui.Lstr(resource=f'{self._r}.titleText'), - maxwidth=300, - color=bui.app.ui_v1.title_color, - scale=0.9, - h_align='center', - v_align='center', - ) - - scroll_height = self._height - 140.0 - self._scroll_width = self._width - (188 + x_inset * 2) - v = self._height - 84.0 - h = 50 + x_inset - b_color = (0.6, 0.53, 0.63) - - scl = ( - 1.055 - if uiscale is bui.UIScale.SMALL - else 1.18 if uiscale is bui.UIScale.MEDIUM else 1.3 - ) - v -= 70.0 * scl - self._new_button = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|new', - position=(h, v), - size=(80, 66.0 * scl), - on_activate_call=self._new_profile, - color=b_color, - button_type='square', - autoselect=True, - textcolor=(0.75, 0.7, 0.8), - text_scale=0.7, - label=bui.Lstr(resource=f'{self._r}.newButtonText'), - ) - v -= 70.0 * scl - self._edit_button = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|edit', - position=(h, v), - size=(80, 66.0 * scl), - on_activate_call=self._edit_profile, - color=b_color, - button_type='square', - autoselect=True, - textcolor=(0.75, 0.7, 0.8), - text_scale=0.7, - label=bui.Lstr(resource=f'{self._r}.editButtonText'), - ) - v -= 70.0 * scl - self._delete_button = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|delete', - position=(h, v), - size=(80, 66.0 * scl), - on_activate_call=self._delete_profile, - color=b_color, - button_type='square', - autoselect=True, - textcolor=(0.75, 0.7, 0.8), - text_scale=0.7, - label=bui.Lstr(resource=f'{self._r}.deleteButtonText'), - ) - - v = self._height - 87 - - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 71), - size=(0, 0), - text=bui.Lstr(resource=f'{self._r}.explanationText'), - color=bui.app.ui_v1.infotextcolor, - maxwidth=self._width * 0.83, - scale=0.6, - h_align='center', - v_align='center', - ) - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - position=(140 + x_inset, v - scroll_height), - size=(self._scroll_width, scroll_height), - ) - bui.widget( - edit=self._scrollwidget, - autoselect=True, - left_widget=self._new_button, - ) - bui.containerwidget( - edit=self._root_widget, selected_child=self._scrollwidget - ) - self._subcontainer = bui.containerwidget( - parent=self._scrollwidget, - size=(self._scroll_width, 32), - background=False, - ) - v -= 255 - self._profiles: dict[str, dict[str, Any]] | None = None - if selected_profile is not None: - type(self).selected_profile = selected_profile - self._profile_widgets: list[bui.Widget] = [] - self._refresh() - - @override - def get_main_window_state(self) -> bui.MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - - minimal_toolbar = self._minimal_toolbar - - return bui.BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - transition=transition, - origin_widget=origin_widget, - minimal_toolbar=minimal_toolbar, - ) - ) - - @override - def main_window_should_preserve_selection(self) -> bool: - return True - - # @override - # def main_window_do_save_shared_state(self, state: dict) -> None: - # state['selected_profile'] = self._selected_profile - - # @override - # def main_window_do_restore_shared_state(self, state: dict) -> None: - # pval = state.get('selected_profile') - # if isinstance(pval, str | None): - # print('RESTORING', pval) - # self._selected_profile = pval - - def _new_profile(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.profile.edit import EditProfileWindow - from bauiv1lib.purchase import PurchaseWindow - - # No-op if we're not the in-control main window. - if not self.main_window_has_control(): - return - - plus = bui.app.plus - assert plus is not None - - # Limit to a handful profiles if they don't have pro-options. - max_non_pro_profiles = plus.get_v1_account_misc_read_val('mnpp', 5) - assert self._profiles is not None - assert bui.app.classic is not None - if ( - bool(False) # Phasing out pro. - and not bui.app.classic.accounts.have_pro_options() - and len(self._profiles) >= max_non_pro_profiles - ): - PurchaseWindow( - items=['pro'], - header_text=bui.Lstr( - resource='unlockThisProfilesText', - subs=[('${NUM}', str(max_non_pro_profiles))], - ), - ) - return - - # Clamp at 100 profiles (otherwise the server will and that's less - # elegant looking). - if len(self._profiles) > 100: - bui.screenmessage( - bui.Lstr( - translate=( - 'serverResponses', - 'Max number of profiles reached.', - ) - ), - color=(1, 0, 0), - ) - bui.getsound('error').play() - return - - self.main_window_replace( - lambda: EditProfileWindow(existing_profile=None) - ) - - def _delete_profile(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib import confirm - - if self.selected_profile is None: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) - ) - return - if self.selected_profile == '__account__': - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource=f'{self._r}.cantDeleteAccountProfileText'), - color=(1, 0, 0), - ) - return - confirm.ConfirmWindow( - bui.Lstr( - resource=f'{self._r}.deleteConfirmText', - subs=[('${PROFILE}', self.selected_profile)], - ), - self._do_delete_profile, - width=350, - ) - - def _do_delete_profile(self) -> None: - plus = bui.app.plus - assert plus is not None - - # Go back to default selection. - type(self).selected_profile = None - - plus.add_v1_account_transaction( - {'type': 'REMOVE_PLAYER_PROFILE', 'name': self.selected_profile} - ) - plus.run_v1_account_transactions() - bui.getsound('shieldDown').play() - self._refresh() - - # Select profile list. - bui.containerwidget( - edit=self._root_widget, selected_child=self._scrollwidget - ) - - def _edit_profile(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.profile.edit import EditProfileWindow - - # No-op if we're not in control. - if not self.main_window_has_control(): - return - - if self.selected_profile is None: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) - ) - return - - self.main_window_replace( - lambda: EditProfileWindow(self.selected_profile) - ) - - def _select(self, name: str, index: int) -> None: - del index # Unused. - type(self).selected_profile = name - - def _refresh(self) -> None: - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - from efro.util import asserttype - from bascenev1 import PlayerProfilesChangedMessage - from bascenev1lib.actor import spazappearance - - assert bui.app.classic is not None - - plus = bui.app.plus - assert plus is not None - - old_selection = self.selected_profile - - # Delete old. - while self._profile_widgets: - self._profile_widgets.pop().delete() - self._profiles = bui.app.config.get('Player Profiles', {}) - assert self._profiles is not None - items = list(self._profiles.items()) - items.sort(key=lambda x: asserttype(x[0], str).lower()) - spazzes = spazappearance.get_appearances() - spazzes.sort() - icon_textures = [ - bui.gettexture(bui.app.classic.spaz_appearances[s].icon_texture) - for s in spazzes - ] - icon_tint_textures = [ - bui.gettexture( - bui.app.classic.spaz_appearances[s].icon_mask_texture - ) - for s in spazzes - ] - index = 0 - y_val = 35 * (len(self._profiles) - 1) - account_name: str | None - if plus.get_v1_account_state() == 'signed_in': - account_name = plus.get_v1_account_display_string() - else: - account_name = None - widget_to_select = None - for p_name, p_info in items: - if p_name == '__account__' and account_name is None: - continue - color, _highlight = bui.app.classic.get_player_profile_colors( - p_name - ) - scl = 1.1 - tval = ( - account_name - if p_name == '__account__' - else bui.app.classic.get_player_profile_icon(p_name) + p_name - ) - - try: - char_index = spazzes.index(p_info['character']) - except Exception: - char_index = spazzes.index('Spaz') - - assert isinstance(tval, str) - txtw = bui.textwidget( - parent=self._subcontainer, - id=f'{self.main_window_id_prefix}|profile{index}', - position=(5, y_val), - size=((self._width - 210) / scl, 28), - text=bui.Lstr(value=f' {tval}'), - h_align='left', - v_align='center', - on_select_call=bui.WeakCall(self._select, p_name, index), - maxwidth=self._scroll_width * 0.86, - corner_scale=scl, - color=bui.safecolor(color, 0.4), - always_highlight=True, - on_activate_call=bui.Call(self._edit_button.activate), - selectable=True, - ) - # We handle reselection of these manually; no need for ids. - bui.widget(edit=txtw, allow_preserve_selection=False) - - character = bui.imagewidget( - parent=self._subcontainer, - position=(0, y_val), - size=(30, 30), - color=(1, 1, 1), - mask_texture=bui.gettexture('characterIconMask'), - tint_color=color, - tint2_color=_highlight, - texture=icon_textures[char_index], - tint_texture=icon_tint_textures[char_index], - ) - if index == 0: - bui.widget(edit=txtw, up_widget=self._back_button) - if self.selected_profile is None: - type(self).selected_profile = p_name - bui.widget(edit=txtw, show_buffer_top=40, show_buffer_bottom=40) - self._profile_widgets.append(txtw) - self._profile_widgets.append(character) - - # Select/show this one if it was previously selected. - # (but defer till after this loop since our height is - # still changing). - if p_name == old_selection or widget_to_select is None: - widget_to_select = txtw - - index += 1 - y_val -= 35 - - bui.containerwidget( - edit=self._subcontainer, - size=(self._scroll_width, index * 35), - ) - if widget_to_select is not None: - bui.containerwidget( - edit=self._subcontainer, - selected_child=widget_to_select, - visible_child=widget_to_select, - ) - - # If there's a team-chooser in existence, tell it the profile-list - # has probably changed. - session = bs.get_foreground_host_session() - if session is not None: - session.handlemessage(PlayerProfilesChangedMessage()) diff --git a/dist/ba_data/python/bauiv1lib/profile/edit.py b/dist/ba_data/python/bauiv1lib/profile/edit.py index 275e269..2f652d1 100644 --- a/dist/ba_data/python/bauiv1lib/profile/edit.py +++ b/dist/ba_data/python/bauiv1lib/profile/edit.py @@ -1,47 +1,43 @@ # Released under the MIT License. See LICENSE for details. # +# pylint: disable=too-many-lines """Provides UI to edit a player profile.""" from __future__ import annotations import random -from typing import cast, override +from typing import cast, override, TYPE_CHECKING from bauiv1lib.colorpicker import ColorPicker from bauiv1lib.characterpicker import CharacterPickerDelegate from bauiv1lib.iconpicker import IconPickerDelegate +from bauiv1lib.connectivity import wait_for_connectivity import bauiv1 as bui import bascenev1 as bs +if TYPE_CHECKING: + from typing import Callable + class EditProfileWindow( bui.MainWindow, CharacterPickerDelegate, IconPickerDelegate ): """Window for editing a player profile.""" - def reload_window(self) -> None: - """Transitions out and recreates ourself.""" - - # Replace ourself with ourself, but keep the same back location. - assert self.main_window_back_state is not None - self.main_window_replace( - lambda: EditProfileWindow(self.getname()), - back_state=self.main_window_back_state, - ) - def __init__( self, existing_profile: str | None, + *, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + on_profile_save: Callable[[str], None] | None = None, + on_profile_delete: Callable[[str], None] | None = None, ): - # FIXME: Tidy this up a bit. # pylint: disable=too-many-branches # pylint: disable=too-many-statements # pylint: disable=too-many-locals assert bui.app.classic is not None - # print(f'EditProfileWindow({id(self)})') plus = bui.app.plus assert plus is not None @@ -51,6 +47,8 @@ class EditProfileWindow( self._spazzes: list[str] = [] self._icon_textures: list[bui.Texture] = [] self._icon_tint_textures: list[bui.Texture] = [] + self._on_profile_save = on_profile_save + self._on_profile_delete = on_profile_delete # Grab profile colors or pick random ones. ( @@ -58,17 +56,17 @@ class EditProfileWindow( self._highlight, ) = bui.app.classic.get_player_profile_colors(existing_profile) uiscale = bui.app.ui_v1.uiscale - self._width = width = 880.0 if uiscale is bui.UIScale.SMALL else 680.0 - self._x_inset = x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 + self._width = width = 1000.0 if uiscale is bui.UIScale.SMALL else 680.0 + self._x_inset = x_inset = 140.0 if uiscale is bui.UIScale.SMALL else 0.0 self._height = height = ( 500.0 if uiscale is bui.UIScale.SMALL - else 400.0 if uiscale is bui.UIScale.MEDIUM else 450.0 + else 450.0 if uiscale is bui.UIScale.MEDIUM else 450.0 ) - yoffs = -42 if uiscale is bui.UIScale.SMALL else 0 + yoffs = 2 if uiscale is bui.UIScale.SMALL else 0 spacing = 40 self._base_scale = ( - 2.0 + 1.72 if uiscale is bui.UIScale.SMALL else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ) @@ -101,6 +99,7 @@ class EditProfileWindow( scale=0.8, label=bui.Lstr(resource='saveText'), ) + bui.widget(edit=save_button, left_widget=cancel_button) bui.widget(edit=cancel_button, right_widget=save_button) bui.containerwidget(edit=self._root_widget, start_button=btn) @@ -347,7 +346,7 @@ class EditProfileWindow( editable=True, padding=4, color=(0.9, 0.9, 0.9, 1.0), - on_return_press_call=bui.Call(save_button.activate), + on_return_press_call=bui.CallStrict(save_button.activate), ) # FIXME hard coded strings are bad @@ -404,7 +403,7 @@ class EditProfileWindow( self._update_clipped_name() self._clipped_name_timer = bui.AppTimer( - 0.333, bui.WeakCall(self._update_clipped_name), repeat=True + 0.333, bui.WeakCallStrict(self._update_clipped_name), repeat=True ) v -= spacing * 3.0 @@ -423,7 +422,9 @@ class EditProfileWindow( origin = self._color_button.get_screen_space_center() bui.buttonwidget( edit=self._color_button, - on_activate_call=bui.WeakCall(self._make_picker, 'color', origin), + on_activate_call=bui.WeakCallStrict( + self._make_picker, 'color', origin + ), ) bui.textwidget( parent=self._root_widget, @@ -493,7 +494,7 @@ class EditProfileWindow( origin = self._highlight_button.get_screen_space_center() bui.buttonwidget( edit=self._highlight_button, - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._make_picker, 'highlight', origin ), ) @@ -509,8 +510,102 @@ class EditProfileWindow( color=bui.app.ui_v1.title_color, maxwidth=120, ) + + if existing_profile is not None: + bui.buttonwidget( + parent=self._root_widget, + position=(self._width * 0.5 - 43, v - 185), + size=(140, 60), + autoselect=True, + scale=0.6, + color=( + (0.5, 0.5, 0.5) + if self._is_account_profile + else (0.65, 0.45, 0.5) + ), + textcolor=( + (0.5, 0.5, 0.5) + if self._is_account_profile + else (1.0, 0.5, 0.5) + ), + label=bui.Lstr(resource='deleteText'), + on_activate_call=bui.WeakCallStrict(self._delete_press), + enable_sound=not self._is_account_profile, + ) + self._update_character() + def reload_window(self) -> None: + """Transitions out and recreates ourself.""" + + # Replace ourself with ourself, but keep the same back location. + assert self.main_window_back_state is not None + self.main_window_replace( + lambda: EditProfileWindow(self.getname()), + back_state=self.main_window_back_state, + ) + + def _delete_press(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.confirm import ConfirmWindow + + if self._is_account_profile: + bui.getsound('error').play() + bui.screenmessage( + bui.Lstr( + resource='playerProfilesWindow.cantDeleteAccountProfileText' + ), + color=(1, 0, 0), + ) + return + + if self._existing_profile is None: + bui.getsound('error').play() + bui.screenmessage( + bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) + ) + return + ConfirmWindow( + bui.Lstr( + resource='playerProfilesWindow.deleteConfirmText', + subs=[('${PROFILE}', self._existing_profile)], + ), + self._do_delete_profile, + width=350, + ) + + def _do_delete_profile(self) -> None: + plus = bui.app.plus + assert plus is not None + + assert self._existing_profile is not None + + # Play a death sound of the character. + classic = bui.app.classic + if classic is not None: + profiles = bui.app.config.get('Player Profiles', {}) + p_info = profiles.get(self._existing_profile) + if p_info: + char = p_info.get('character', 'Spaz') + appearance = classic.spaz_appearances.get(char) + if appearance: + bui.getsound(random.choice(appearance.death_sounds)).play() + + plus.add_v1_account_transaction( + {'type': 'REMOVE_PLAYER_PROFILE', 'name': self._existing_profile} + ) + + plus.run_v1_account_transactions() + bui.getsound('shieldDown').play() + + if self._on_profile_delete is not None: + try: + self._on_profile_delete(self._existing_profile) + except Exception: + bui.balog.exception('Error in _on_profile_delete cb.') + + self.main_window_back() + @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -518,6 +613,8 @@ class EditProfileWindow( # Pull things out of self here; if we do it within the lambda # we'll keep ourself alive which is bad. + on_profile_save = self._on_profile_save + on_profile_delete = self._on_profile_delete existing_profile = self._existing_profile return bui.BasicMainWindowState( @@ -525,6 +622,8 @@ class EditProfileWindow( transition=transition, origin_widget=origin_widget, existing_profile=existing_profile, + on_profile_save=on_profile_save, + on_profile_delete=on_profile_delete, ) ) @@ -648,12 +747,30 @@ class EditProfileWindow( @override def on_icon_picker_get_more_press(self) -> None: """User wants to get more icons.""" - from bauiv1lib.store.browser import StoreBrowserWindow + import bacommon.docui.v1 as dui1 - self.main_window_replace( - lambda: StoreBrowserWindow( - minimal_toolbars=True, - show_tab=StoreBrowserWindow.TabID.ICONS, + from bauiv1lib.store import StoreUIController + + if not self._ensure_signed_in( + origin_widget=bui.get_special_widget('store_button') + ): + return + + # Because profile editing is happening within an auxiliary + # window stack, we need to bring up the store as a regular + # non-auxiliary window pushed onto our stack. If we did the + # simple thing and triggered it as an auxiliary window then it + # would *replace* our stack and we wouldn't be able to get back + # to our editing. + wait_for_connectivity( + on_connected=lambda: self.main_window_replace( + bui.CallStrict( + StoreUIController().create_window, + dui1.Request('/'), + origin_widget=bui.get_special_widget('store_button'), + auxiliary_style=False, + ), + extra_type_id=StoreUIController.get_window_extra_type_id(), ) ) @@ -671,14 +788,46 @@ class EditProfileWindow( ) self._update_character() + def _ensure_signed_in( + self, *, origin_widget: bui.Widget | None = None + ) -> bool: + """Make sure we're signed in (requiring modern v2 accounts).""" + from bauiv1lib.account.signin import show_sign_in_prompt + + plus = bui.app.plus + if plus is None: + bui.screenmessage('This requires plus.', color=(1, 0, 0)) + bui.getsound('error').play() + return False + if plus.accounts.primary is None: + show_sign_in_prompt(origin_widget=origin_widget) + return False + return True + @override def on_character_picker_get_more_press(self) -> None: - from bauiv1lib.store.browser import StoreBrowserWindow + import bacommon.docui.v1 as dui1 - self.main_window_replace( - lambda: StoreBrowserWindow( - minimal_toolbars=True, - show_tab=StoreBrowserWindow.TabID.CHARACTERS, + from bauiv1lib.store import StoreUIController + + if not self._ensure_signed_in( + origin_widget=bui.get_special_widget('store_button') + ): + return + + # Set this up as a non-auxiliary window so we can nav back to + # char editing (otherwise it would replace the whole inventory + # stack). Also don't set uiopenstateid in this case since we don't + # want store button to glow (since inventory button already is). + wait_for_connectivity( + on_connected=lambda: self.main_window_replace( + bui.CallStrict( + StoreUIController().create_window, + dui1.Request('/'), + origin_widget=bui.get_special_widget('store_button'), + auxiliary_style=False, + ), + extra_type_id=StoreUIController.get_window_extra_type_id(), ) ) @@ -824,9 +973,6 @@ class EditProfileWindow( def save(self, transition_out: bool = True) -> bool: """Save has been selected.""" - # pylint: disable=cyclic-import - - from bauiv1lib.profile.browser import ProfileBrowserWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -851,8 +997,11 @@ class EditProfileWindow( bui.getsound('error').play() return False - # Set the profile-browser to have this one selected by default. - ProfileBrowserWindow.selected_profile = new_name + if self._on_profile_save is not None: + try: + self._on_profile_save(new_name) + except Exception: + bui.balog.exception('Error in _on_profile_save cb.') if transition_out: bui.getsound('gunCocking').play() diff --git a/dist/ba_data/python/bauiv1lib/profile/upgrade.py b/dist/ba_data/python/bauiv1lib/profile/upgrade.py index 0866f68..06a2900 100644 --- a/dist/ba_data/python/bauiv1lib/profile/upgrade.py +++ b/dist/ba_data/python/bauiv1lib/profile/upgrade.py @@ -8,7 +8,7 @@ import time import weakref from typing import TYPE_CHECKING -import bacommon.bs +import bacommon.classic import bauiv1 as bui @@ -142,20 +142,20 @@ class ProfileUpgradeWindow(bui.Window): assert plus.accounts.primary is not None with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.GlobalProfileCheckMessage(self._name), - on_response=bui.WeakCall( + bacommon.classic.GlobalProfileCheckMessage(self._name), + on_response=bui.WeakCallPartial( self._on_global_profile_check_response ), ) self._status: str | None = 'waiting' self._update_timer = bui.AppTimer( - 1.023, bui.WeakCall(self._update), repeat=True + 1.023, bui.WeakCallStrict(self._update), repeat=True ) self._update() def _on_global_profile_check_response( - self, response: bacommon.bs.GlobalProfileCheckResponse | Exception + self, response: bacommon.classic.GlobalProfileCheckResponse | Exception ) -> None: if isinstance(response, Exception): bui.textwidget( diff --git a/dist/ba_data/python/bauiv1lib/purchase.py b/dist/ba_data/python/bauiv1lib/purchase.py deleted file mode 100644 index 911cd2c..0000000 --- a/dist/ba_data/python/bauiv1lib/purchase.py +++ /dev/null @@ -1,209 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI related to purchasing items.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class PurchaseWindow(bui.Window): - """Window for purchasing one or more items.""" - - def __init__( - self, - items: list[str], - origin_widget: bui.Widget | None = None, - header_text: bui.Lstr | None = None, - ): - from bauiv1lib.store.item import instantiate_store_item_display - - plus = bui.app.plus - assert plus is not None - - assert bui.app.classic is not None - store = bui.app.classic.store - - if header_text is None: - header_text = bui.Lstr( - resource='unlockThisText', - fallback_resource='unlockThisInTheStoreText', - ) - if len(items) != 1: - raise ValueError('expected exactly 1 item') - - self._idprefix = bui.app.ui_v1.new_id_prefix('purchase') - self._items = list(items) - self._width = 580 - self._height = 520 - uiscale = bui.app.ui_v1.uiscale - - if origin_widget is not None: - scale_origin = origin_widget.get_screen_space_center() - else: - scale_origin = None - - super().__init__( - root_widget=bui.containerwidget( - parent=bui.get_special_widget('overlay_stack'), - size=(self._width, self._height), - transition='in_scale', - toolbar_visibility='menu_store', - scale=( - 1.2 - if uiscale is bui.UIScale.SMALL - else 1.1 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - darken_behind=True, - ) - ) - self._is_double = False - self._title_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 30), - size=(0, 0), - text=header_text, - h_align='center', - v_align='center', - maxwidth=self._width * 0.9 - 120, - scale=1.2, - color=(1, 0.8, 0.3, 1), - ) - size = store.get_store_item_display_size(items[0]) - display: dict[str, Any] = {} - instantiate_store_item_display( - items[0], - display, - idprefix=self._idprefix, - parent_widget=self._root_widget, - b_pos=( - self._width * 0.5 - - size[0] * 0.5 - + 10 - - ((size[0] * 0.5 + 30) if self._is_double else 0), - self._height * 0.5 - - size[1] * 0.5 - + 30 - + (20 if self._is_double else 0), - ), - b_width=size[0], - b_height=size[1], - button=False, - ) - - # Wire up the parts we need. - if self._is_double: - pass # not working - else: - if self._items == ['pro']: - price_str = plus.get_price(self._items[0]) - pyoffs = -15 - else: - pyoffs = 0 - price = self._price = plus.get_v1_account_misc_read_val( - 'price.' + str(items[0]), -1 - ) - price_str = bui.charstr(bui.SpecialChar.TICKET) + str(price) - self._price_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, 150 + pyoffs), - size=(0, 0), - text=price_str, - h_align='center', - v_align='center', - maxwidth=self._width * 0.9, - scale=1.4, - color=(0.2, 1, 0.2), - ) - - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - position=(50, 40), - size=(150, 60), - scale=1.0, - on_activate_call=self._cancel, - autoselect=True, - label=bui.Lstr(resource='cancelText'), - ) - self._purchase_button = bui.buttonwidget( - parent=self._root_widget, - position=(self._width - 200, 40), - size=(150, 60), - scale=1.0, - on_activate_call=self._purchase, - autoselect=True, - label=bui.Lstr(resource='store.purchaseText'), - ) - - bui.containerwidget( - edit=self._root_widget, - cancel_button=self._cancel_button, - start_button=self._purchase_button, - selected_child=self._purchase_button, - ) - - def _update(self) -> None: - can_die = False - - plus = bui.app.plus - assert plus is not None - - # We go away if we see that our target item is owned. - if self._items == ['pro']: - assert bui.app.classic is not None - if bui.app.classic.accounts.have_pro(): - can_die = True - else: - assert bui.app.classic is not None - if self._items[0] in bui.app.classic.purchases: - can_die = True - - if can_die: - bui.containerwidget(edit=self._root_widget, transition='out_scale') - - def _purchase(self) -> None: - - plus = bui.app.plus - assert plus is not None - classic = bui.app.classic - assert classic is not None - - if self._items == ['pro']: - plus.purchase('pro') - else: - ticket_count: int | None - try: - ticket_count = classic.tickets - except Exception: - ticket_count = None - if ticket_count is not None and ticket_count < self._price: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='notEnoughTicketsText'), - color=(1, 0, 0), - ) - return - - def do_it() -> None: - assert plus is not None - - plus.in_game_purchase(self._items[0], self._price) - - bui.getsound('swish').play() - do_it() - - def _cancel(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/dist/ba_data/python/bauiv1lib/qrcode.py b/dist/ba_data/python/bauiv1lib/qrcode.py index c76749c..aac2546 100644 --- a/dist/ba_data/python/bauiv1lib/qrcode.py +++ b/dist/ba_data/python/bauiv1lib/qrcode.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides functionality for displaying QR codes.""" + from __future__ import annotations from typing import override diff --git a/dist/ba_data/python/bauiv1lib/radiogroup.py b/dist/ba_data/python/bauiv1lib/radiogroup.py index 6492786..e1236d3 100644 --- a/dist/ba_data/python/bauiv1lib/radiogroup.py +++ b/dist/ba_data/python/bauiv1lib/radiogroup.py @@ -33,7 +33,7 @@ def make_radio_group( edit=check_box, value=(value == value_names[i]), is_radio_button=True, - on_value_change_call=bui.Call( + on_value_change_call=bui.CallPartial( _radio_press, value_names[i], [c for c in check_boxes if c != check_box], diff --git a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py index c5eb343..a3bb211 100644 --- a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py +++ b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py @@ -22,6 +22,9 @@ class ResourceTypeInfoWindow(PopupWindow): origin_widget: bui.Widget, ): assert bui.app.classic is not None + + self._uiopenstate = bui.UIOpenState(f'resourcetypeinfo{resource_type}') + uiscale = bui.app.ui_v1.uiscale scale = ( 2.0 @@ -87,7 +90,9 @@ class ResourceTypeInfoWindow(PopupWindow): label=bui.Lstr(resource='tokens.getTokensText'), size=(bwidth, bheight), autoselect=True, - on_activate_call=bui.WeakCall(self._on_get_tokens_press), + on_activate_call=bui.WeakCallStrict( + self._on_get_tokens_press + ), ) elif resource_type == 'trophies': diff --git a/dist/ba_data/python/bauiv1lib/sendinfo.py b/dist/ba_data/python/bauiv1lib/sendinfo.py index ddb63cc..7eb3281 100644 --- a/dist/ba_data/python/bauiv1lib/sendinfo.py +++ b/dist/ba_data/python/bauiv1lib/sendinfo.py @@ -377,7 +377,7 @@ class SendInfoWindowLegacyModal(bui.Window): async def _send_info(description: str) -> None: - from bacommon.bs import SendInfoMessage + from bacommon.classic import SendInfoMessage plus = bui.app.plus assert plus is not None diff --git a/dist/ba_data/python/bauiv1lib/settings/advanced.py b/dist/ba_data/python/bauiv1lib/settings/advanced.py index d7a3f2b..4dd4420 100644 --- a/dist/ba_data/python/bauiv1lib/settings/advanced.py +++ b/dist/ba_data/python/bauiv1lib/settings/advanced.py @@ -10,9 +10,9 @@ import logging from typing import TYPE_CHECKING, override from bacommon.locale import LocaleResolved -from bauiv1lib.popup import PopupMenu -from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top +from bauiv1lib.popup import PopupMenu if TYPE_CHECKING: from typing import Any @@ -216,14 +216,14 @@ class AdvancedSettingsWindow(bui.MainWindow): # Rebuild periodically to pick up language changes/additions/etc. self._rebuild_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._rebuild), repeat=True + 1.0, bui.WeakCallStrict(self._rebuild), repeat=True ) # Fetch the list of completed languages. bui.app.classic.master_server_v1_get( 'bsLangGetCompleted', {'b': app.env.engine_build_number}, - callback=bui.WeakCall(self._completed_langs_cb), + callback=bui.WeakCallPartial(self._completed_langs_cb), ) @override @@ -414,10 +414,10 @@ class AdvancedSettingsWindow(bui.MainWindow): button_id=f'{self.main_window_id_prefix}|language', position=(210, v - 19), width=250, - opening_call=bui.WeakCall(self._on_menu_open), - closing_call=bui.WeakCall(self._on_menu_close), + opening_call=bui.WeakCallStrict(self._on_menu_open), + closing_call=bui.WeakCallStrict(self._on_menu_close), autoselect=True, - on_value_change_call=bui.WeakCall(self._on_menu_choice), + on_value_change_call=bui.WeakCallPartial(self._on_menu_choice), choices=['Auto'] + available_languages, button_size=(300, 60), choices_display=( @@ -478,7 +478,7 @@ class AdvancedSettingsWindow(bui.MainWindow): subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], ), autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( bui.open_url, 'https://legacy.ballistica.net/translate' ), ) @@ -509,7 +509,7 @@ class AdvancedSettingsWindow(bui.MainWindow): textcolor=(0.8, 0.8, 0.8), value=lang_inform, text=bui.Lstr(resource=f'{self._r}.translationInformMe'), - on_value_change_call=bui.WeakCall( + on_value_change_call=bui.WeakCallPartial( self._on_lang_inform_value_change ), ) @@ -686,7 +686,7 @@ class AdvancedSettingsWindow(bui.MainWindow): autoselect=True, label=bui.Lstr(resource=f'{self._r}.moddingGuideText'), text_scale=1.0, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( bui.open_url, 'https://ballistica.net/wiki/modding-guide' ), ) @@ -913,7 +913,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self.main_window_save_shared_state() - bui.apptimer(0.1, bui.WeakCall(self._rebuild)) + bui.apptimer(0.1, bui.WeakCallStrict(self._rebuild)) def _completed_langs_cb(self, results: dict[str, Any] | None) -> None: if results is not None and results['langs'] is not None: @@ -922,4 +922,4 @@ class AdvancedSettingsWindow(bui.MainWindow): else: self._complete_langs_list = None self._complete_langs_error = True - bui.apptimer(0.001, bui.WeakCall(self._update_lang_status)) + bui.apptimer(0.001, bui.WeakCallStrict(self._update_lang_status)) diff --git a/dist/ba_data/python/bauiv1lib/settings/allsettings.py b/dist/ba_data/python/bauiv1lib/settings/allsettings.py index 7065061..2f92110 100644 --- a/dist/ba_data/python/bauiv1lib/settings/allsettings.py +++ b/dist/ba_data/python/bauiv1lib/settings/allsettings.py @@ -27,6 +27,8 @@ class AllSettingsWindow(bui.MainWindow): # have a visual hitch when the user taps them. bui.app.threadpool.submit_no_wait(self._preload_modules) + self._uiopenstate = bui.UIOpenState('settings') + bui.set_analytics_screen('Settings Window') assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale @@ -245,7 +247,10 @@ class AllSettingsWindow(bui.MainWindow): return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget - ) + ), + # Keeps our icon glowing as long as this is in the back + # stack. + uiopenstate=self._uiopenstate, ) @override diff --git a/dist/ba_data/python/bauiv1lib/settings/audio.py b/dist/ba_data/python/bauiv1lib/settings/audio.py index 14bf15c..97c897a 100644 --- a/dist/ba_data/python/bauiv1lib/settings/audio.py +++ b/dist/ba_data/python/bauiv1lib/settings/audio.py @@ -214,7 +214,8 @@ class AudioSettingsWindow(bui.MainWindow): color=(0.5, 1, 0.5), ) bui.apptimer( - 1.0, bui.Call(bui.request_permission, bui.Permission.STORAGE) + 1.0, + bui.CallStrict(bui.request_permission, bui.Permission.STORAGE), ) return diff --git a/dist/ba_data/python/bauiv1lib/settings/benchmarks.py b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py index 4296108..522ece9 100644 --- a/dist/ba_data/python/bauiv1lib/settings/benchmarks.py +++ b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py @@ -266,7 +266,9 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): size=(28, 28), label='-', autoselect=True, - on_activate_call=bui.Call(self._stress_test_player_count_decrement), + on_activate_call=bui.CallStrict( + self._stress_test_player_count_decrement + ), repeat=True, enable_sound=True, ) @@ -277,7 +279,9 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): size=(28, 28), label='+', autoselect=True, - on_activate_call=bui.Call(self._stress_test_player_count_increment), + on_activate_call=bui.CallStrict( + self._stress_test_player_count_increment + ), repeat=True, enable_sound=True, ) @@ -313,7 +317,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): size=(28, 28), label='-', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._stress_test_round_duration_decrement ), repeat=True, @@ -326,7 +330,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): size=(28, 28), label='+', autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._stress_test_round_duration_increment ), repeat=True, diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepad.py b/dist/ba_data/python/bauiv1lib/settings/gamepad.py index 48740f0..2c4aad7 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepad.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepad.py @@ -585,8 +585,8 @@ class GamepadSettingsWindow(bui.MainWindow): if 'analogStickUD' + self._ext in self._settings else 6 if self._is_secondary else None ) - assert isinstance(sval1, (int, type(None))) - assert isinstance(sval2, (int, type(None))) + assert isinstance(sval1, int | None) + assert isinstance(sval2, int | None) if sval1 is not None and sval2 is not None: return ( self._inputdevice.get_axis_name(sval1) @@ -630,7 +630,7 @@ class GamepadSettingsWindow(bui.MainWindow): if 'dpad' + self._ext in self._settings else 2 if self._is_secondary else None ) - assert isinstance(dpadnum, (int, type(None))) + assert isinstance(dpadnum, int | None) if dpadnum is not None: return bui.Lstr( value='${A} ${B}', @@ -811,7 +811,7 @@ class GamepadSettingsWindow(bui.MainWindow): self._textwidgets[button] = txt bui.buttonwidget( edit=btn, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( AwaitGamepadInputWindow, self._inputdevice, button, @@ -1037,9 +1037,11 @@ class AwaitGamepadInputWindow(bui.Window): text=str(self._counter), ) self._decrement_timer: bui.AppTimer | None = bui.AppTimer( - 1.0, bui.Call(self._decrement), repeat=True + 1.0, bui.CallStrict(self._decrement), repeat=True + ) + bs.capture_game_controller_input( + bui.WeakCallPartial(self._event_callback) ) - bs.capture_game_controller_input(bui.WeakCall(self._event_callback)) def die(self) -> None: """Kill the window.""" diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py b/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py index ab06216..9738505 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py @@ -415,7 +415,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): label=bui.Lstr(resource=f'{self._r}.clearText'), size=(110, 50), scale=0.7, - on_activate_call=bui.Call(self._clear_control, control), + on_activate_call=bui.CallStrict(self._clear_control, control), ) bui.widget(edit=btn, right_widget=btn2) @@ -439,7 +439,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): self._textwidgets[control] = txt bui.buttonwidget( edit=btn, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( AwaitGamepadInputWindow, self._parent_window.get_input(), control, @@ -509,7 +509,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): position=(330 + x_offset, position[1] + 4), size=(28, 28), label='-', - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._inc, control, min_val, max_val, -increment ), repeat=True, @@ -521,7 +521,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): position=(380 + x_offset, position[1] + 4), size=(28, 28), label='+', - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._inc, control, min_val, max_val, increment ), repeat=True, diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py index dbbf268..5af7af9 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py @@ -106,7 +106,7 @@ class GamepadSelectWindow(bui.MainWindow): ) bs.capture_game_controller_input( - bui.WeakCall(self.gamepad_configure_callback) + bui.WeakCallPartial(self.gamepad_configure_callback) ) def __del__(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/settings/graphics.py b/dist/ba_data/python/bauiv1lib/settings/graphics.py index e538d3f..a898619 100644 --- a/dist/ba_data/python/bauiv1lib/settings/graphics.py +++ b/dist/ba_data/python/bauiv1lib/settings/graphics.py @@ -455,7 +455,7 @@ class GraphicsSettingsWindow(bui.MainWindow): # Make a timer to update our controls in case the config changes # under us. self._update_timer = bui.AppTimer( - 0.25, bui.WeakCall(self._update_controls), repeat=True + 0.25, bui.WeakCallStrict(self._update_controls), repeat=True ) @override diff --git a/dist/ba_data/python/bauiv1lib/settings/keyboard.py b/dist/ba_data/python/bauiv1lib/settings/keyboard.py index 63f3cc1..73b4072 100644 --- a/dist/ba_data/python/bauiv1lib/settings/keyboard.py +++ b/dist/ba_data/python/bauiv1lib/settings/keyboard.py @@ -310,7 +310,7 @@ class ConfigKeyboardWindow(bui.MainWindow): bui.buttonwidget( edit=btn, autoselect=True, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( AwaitKeyboardInputWindow, button, txt, self._settings ), ) @@ -468,7 +468,7 @@ class AwaitKeyboardInputWindow(bui.Window): self._decrement_timer: bui.AppTimer | None = bui.AppTimer( 1.0, self._decrement, repeat=True ) - bs.capture_keyboard_input(bui.WeakCall(self._button_callback)) + bs.capture_keyboard_input(bui.WeakCallPartial(self._button_callback)) def __del__(self) -> None: bs.release_keyboard_input() diff --git a/dist/ba_data/python/bauiv1lib/settings/nettesting.py b/dist/ba_data/python/bauiv1lib/settings/nettesting.py index b6a521f..018d05b 100644 --- a/dist/ba_data/python/bauiv1lib/settings/nettesting.py +++ b/dist/ba_data/python/bauiv1lib/settings/nettesting.py @@ -163,7 +163,9 @@ class NetTestingWindow(bui.MainWindow): # 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(target=bui.Call(_run_diagnostics, weakref.ref(self))).start() + Thread( + target=bui.CallStrict(_run_diagnostics, weakref.ref(self)) + ).start() @override def get_main_window_state(self) -> bui.MainWindowState: diff --git a/dist/ba_data/python/bauiv1lib/settings/plugins.py b/dist/ba_data/python/bauiv1lib/settings/plugins.py index c6b78b2..9fdcfdb 100644 --- a/dist/ba_data/python/bauiv1lib/settings/plugins.py +++ b/dist/ba_data/python/bauiv1lib/settings/plugins.py @@ -35,6 +35,7 @@ class PluginWindow(bui.MainWindow): transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): + # pylint: disable=too-many-statements # pylint: disable=too-many-locals app = bui.app @@ -54,7 +55,7 @@ class PluginWindow(bui.MainWindow): # screen shape at small ui scale. screensize = bui.get_virtual_screen_size() scale = ( - 1.9 + 1.7 if uiscale is bui.UIScale.SMALL else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 ) @@ -121,16 +122,21 @@ class PluginWindow(bui.MainWindow): size=(0, 0), text=bui.Lstr(resource='pluginsText'), color=app.ui_v1.title_color, - maxwidth=170, + maxwidth=140, h_align='center', v_align='center', ) - settings_button_x = ( - self._width * 0.5 - + self._scroll_width * 0.5 - - (100 if uiscale is bui.UIScale.SMALL else 40) - ) + settings_button_x = self._width * 0.5 + self._scroll_width * 0.5 - 40 + if uiscale is bui.UIScale.SMALL: + # In small UI there's stuff top right we need to avoid. + if bui.in_main_menu(): + # Squads button + settings_button_x -= 65 + else: + # Squads and settings buttons + settings_button_x -= 115 + button_row_yoffs = yoffs + (-2 if uiscale is bui.UIScale.SMALL else 10) self._num_plugins_text = bui.textwidget( @@ -150,7 +156,7 @@ class PluginWindow(bui.MainWindow): size=(130, 60), label=bui.Lstr(resource='allText'), autoselect=True, - on_activate_call=bui.WeakCall(self._show_category_options), + on_activate_call=bui.WeakCallStrict(self._show_category_options), color=(0.55, 0.73, 0.25), iconscale=1.2, ) @@ -378,7 +384,7 @@ class PluginWindow(bui.MainWindow): ), position=(10, item_y), size=(self._scroll_width - 40, 50), - on_value_change_call=bui.Call( + on_value_change_call=bui.CallPartial( self._check_value_changed, plugspec ), textcolor=( @@ -402,7 +408,9 @@ class PluginWindow(bui.MainWindow): ) bui.buttonwidget( edit=button, - on_activate_call=bui.Call(plugin.show_settings_ui, button), + on_activate_call=bui.CallStrict( + plugin.show_settings_ui, button + ), ) else: button = None diff --git a/dist/ba_data/python/bauiv1lib/settings/testing.py b/dist/ba_data/python/bauiv1lib/settings/testing.py index 712ce93..b439d99 100644 --- a/dist/ba_data/python/bauiv1lib/settings/testing.py +++ b/dist/ba_data/python/bauiv1lib/settings/testing.py @@ -171,7 +171,9 @@ class TestingWindow(bui.MainWindow): left_widget=self._back_button, button_type='square', label='-', - on_activate_call=bui.Call(self._on_minus_press, entry['name']), + on_activate_call=bui.CallStrict( + self._on_minus_press, entry['name'] + ), ) if i == 0: bui.widget(edit=btn, up_widget=self._back_button) @@ -192,7 +194,9 @@ class TestingWindow(bui.MainWindow): repeat=True, button_type='square', label='+', - on_activate_call=bui.Call(self._on_plus_press, entry['name']), + on_activate_call=bui.CallStrict( + self._on_plus_press, entry['name'] + ), ) if i == 0: bui.widget(edit=btn, up_widget=self._back_button) diff --git a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py index 580097a..5ea269a 100644 --- a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py +++ b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """UI settings functionality related to touchscreens.""" + from __future__ import annotations from typing import override diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py index 6ab5c52..2a78067 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py @@ -13,8 +13,6 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any -REQUIRE_PRO = False - class SoundtrackBrowserWindow(bui.MainWindow): """Window for browsing soundtracks.""" @@ -225,7 +223,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): # Keep our lock images up to date/etc. self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() @@ -284,14 +282,8 @@ class SoundtrackBrowserWindow(bui.MainWindow): self._save_state() def _update(self) -> None: - have_pro = ( - bui.app.classic is None - or bui.app.classic.accounts.have_pro_options() - ) for lock in self._lock_images: - bui.imagewidget( - edit=lock, opacity=0.0 if (have_pro or not REQUIRE_PRO) else 1.0 - ) + bui.imagewidget(edit=lock, opacity=0.0 if bool(True) else 1.0) def _do_delete_soundtrack(self) -> None: cfg = bui.app.config @@ -309,15 +301,8 @@ class SoundtrackBrowserWindow(bui.MainWindow): def _delete_soundtrack(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.confirm import ConfirmWindow - if REQUIRE_PRO and ( - bui.app.classic is not None - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return if self._selected_soundtrack is None: return if self._selected_soundtrack == '__default__': @@ -338,15 +323,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): ) def _duplicate_soundtrack(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow - if REQUIRE_PRO and ( - bui.app.classic is not None - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return cfg = bui.app.config cfg.setdefault('Soundtracks', {}) @@ -408,34 +385,16 @@ class SoundtrackBrowserWindow(bui.MainWindow): ) def _edit_soundtrack_with_sound(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow - - if REQUIRE_PRO and ( - bui.app.classic is not None - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return bui.getsound('swish').play() self._edit_soundtrack() def _edit_soundtrack(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow # no-op if we don't have control. if not self.main_window_has_control(): return - if REQUIRE_PRO and ( - bui.app.classic is not None - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return - if self._selected_soundtrack is None: return @@ -489,7 +448,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): v_align='center', maxwidth=self._width - 110, always_highlight=True, - on_select_call=bui.WeakCall(self._select, pname, index), + on_select_call=bui.WeakCallStrict(self._select, pname, index), on_activate_call=self._edit_soundtrack_with_sound, selectable=True, ) @@ -530,7 +489,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): # Eww need to run this in a timer so it happens after our select # callbacks. With a small-enough time sometimes it happens before # anyway. Ew. need a way to just schedule a callable i guess. - bui.apptimer(0.1, bui.WeakCall(self._set_allow_changing)) + bui.apptimer(0.1, bui.WeakCallStrict(self._set_allow_changing)) def _set_allow_changing(self) -> None: self._allow_changing_soundtracks = True @@ -539,21 +498,12 @@ class SoundtrackBrowserWindow(bui.MainWindow): self._select(self._selected_soundtrack, self._selected_soundtrack_index) def _new_soundtrack(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow # no-op if we're not in control. if not self.main_window_has_control(): return - if REQUIRE_PRO and ( - bui.app.classic is not None - and not bui.app.classic.accounts.have_pro_options() - ): - PurchaseWindow(items=['pro']) - return - self.main_window_replace( lambda: SoundtrackEditWindow(existing_soundtrack=None) ) diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py index 1dfd58a..f79248f 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py @@ -313,7 +313,7 @@ class SoundtrackEditWindow(bui.MainWindow): size=(230, 32), label=self._get_entry_button_display_name(entry), text_scale=0.6, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._get_entry, song_type, entry, type_name ), icon=( @@ -364,7 +364,9 @@ class SoundtrackEditWindow(bui.MainWindow): size=(50, 32), label=bui.Lstr(resource=f'{self._r}.testText'), text_scale=0.6, - on_activate_call=bui.Call(self._test, bs.MusicType(song_type)), + on_activate_call=bui.CallStrict( + self._test, bs.MusicType(song_type) + ), up_widget=( prev_test_button if prev_test_button is not None @@ -429,7 +431,7 @@ class SoundtrackEditWindow(bui.MainWindow): } new_win = self.main_window_replace( lambda: music.get_music_player().select_entry( - bui.Call(self._restore_editor, state, song_type), + bui.CallPartial(self._restore_editor, state, song_type), entry, selection_target_name, ) diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py index 4061aef..281dd87 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Provides UI for selecting soundtrack entry types.""" + from __future__ import annotations import copy diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py index 2c47085..65af31e 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py @@ -126,7 +126,7 @@ class MacMusicAppPlaylistSelectWindow(bui.MainWindow): v_align='center', maxwidth=self._width - 110, selectable=True, - on_activate_call=bui.Call(self._sel, playlist), + on_activate_call=bui.CallStrict(self._sel, playlist), click_activate=True, ) bui.widget(edit=txt, show_buffer_top=40, show_buffer_bottom=40) diff --git a/dist/ba_data/python/bauiv1lib/store.py b/dist/ba_data/python/bauiv1lib/store.py new file mode 100644 index 0000000..be521f0 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/store.py @@ -0,0 +1,59 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Shiny new doc-ui based store.""" + +from __future__ import annotations + +from typing import override, TYPE_CHECKING + +from bauiv1lib.docui import DocUIController + +import bauiv1 as bui + +if TYPE_CHECKING: + from bacommon.docui import DocUIRequest, DocUIResponse + + from bauiv1lib.docui import DocUILocalAction + + +class StoreUIController(DocUIController): + """DocUI setup for store.""" + + @override + def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: + return self.fulfill_request_cloud(request, 'classicstore') + + @override + def local_action(self, action: DocUILocalAction) -> None: + + if action.name == 'get_tokens': + self._get_tokens(action) + elif action.name == 'restore_purchases': + self._restore_purchases() + else: + bui.screenmessage( + f'Invalid local-action "{action.name}".', color=(1, 0, 0) + ) + bui.getsound('error').play() + + def _restore_purchases(self) -> None: + + plus = bui.app.plus + assert plus is not None + + # We should always be signed in here. Make noise if not. + if plus.accounts.primary is None: + bui.screenmessage( + bui.Lstr(resource='notSignedInText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + plus.restore_purchases() + + def _get_tokens(self, action: DocUILocalAction) -> None: + from bauiv1lib.gettokens import show_get_tokens_window + + bui.getsound('swish').play() + + show_get_tokens_window(origin_widget=bui.existing(action.widget)) diff --git a/dist/ba_data/python/bauiv1lib/store/__init__.py b/dist/ba_data/python/bauiv1lib/store/__init__.py deleted file mode 100644 index 867b171..0000000 --- a/dist/ba_data/python/bauiv1lib/store/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Released under the MIT License. See LICENSE for details. diff --git a/dist/ba_data/python/bauiv1lib/store/browser.py b/dist/ba_data/python/bauiv1lib/store/browser.py deleted file mode 100644 index 78991e4..0000000 --- a/dist/ba_data/python/bauiv1lib/store/browser.py +++ /dev/null @@ -1,1249 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI for browsing the store.""" -# pylint: disable=too-many-lines -from __future__ import annotations - -import os -import time -import copy -import math -import logging -import weakref -import datetime -from enum import Enum -from threading import Thread -from typing import TYPE_CHECKING, override - -from efro.util import utc_now -from efro.error import CommunicationError -import bacommon.cloud -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any, Callable, Sequence - -MERCH_LINK_KEY = 'Merch Link' - - -class StoreBrowserWindow(bui.MainWindow): - """Window for browsing the store.""" - - class TabID(Enum): - """Our available tab types.""" - - # EXTRAS = 'extras' - MAPS = 'maps' - MINIGAMES = 'minigames' - CHARACTERS = 'characters' - ICONS = 'icons' - - def __init__( - self, - transition: str | None = 'in_right', - origin_widget: bui.Widget | None = None, - *, - show_tab: StoreBrowserWindow.TabID | None = None, - minimal_toolbars: bool = False, - auxiliary_style: bool = True, - ): - # pylint: disable=too-many-statements - # pylint: disable=too-many-locals - from bauiv1lib.tabs import TabRow - from bauiv1 import SpecialChar - - app = bui.app - assert app.classic is not None - uiscale = app.ui_v1.uiscale - - bui.set_analytics_screen('Store Window') - - self.button_infos: dict[str, dict[str, Any]] | None = None - self.update_buttons_timer: bui.AppTimer | None = None - self._status_textwidget_update_timer = None - - self._show_tab = show_tab - self._width = ( - 1800 - if uiscale is bui.UIScale.SMALL - else 1000 if uiscale is bui.UIScale.MEDIUM else 1120 - ) - self._height = ( - 1200 - if uiscale is bui.UIScale.SMALL - else 700 if uiscale is bui.UIScale.MEDIUM else 800 - ) - self._current_tab: StoreBrowserWindow.TabID | None = None - - self.request: Any = None - self._r = 'store' - self._last_buy_time: float | None = None - - # Do some fancy math to fill all available screen area up to the - # size of our backing container. This lets us fit to the exact - # screen shape at small ui scale. - screensize = bui.get_virtual_screen_size() - scale = ( - 1.5 - if uiscale is bui.UIScale.SMALL - else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 - ) - - # Calc screen size in our local container space and clamp to a - # bit smaller than our container size. - target_width = min(self._width - 120, screensize[0] / scale) - target_height = min(self._height - 140, screensize[1] / scale) - - # To get top/left coords, go to the center of our window and - # offset by half the width/height of our target area. - yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 - - self._scroll_width = target_width - self._scroll_height = target_height - 59 - self._scroll_bottom = yoffs - 87 - self._scroll_height - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - toolbar_visibility=( - 'menu_store' - if (uiscale is bui.UIScale.SMALL or minimal_toolbars) - else 'menu_full' - ), - toolbar_cancel_button_style=( - 'close' if auxiliary_style else 'back' - ), - scale=scale, - ), - transition=transition, - origin_widget=origin_widget, - # We're affected by screen size only at small ui-scale. - refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, - ) - - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|back', - position=(70, yoffs - 37), - size=(60, 60), - scale=1.1, - autoselect=True, - label=bui.charstr( - SpecialChar.CLOSE if auxiliary_style else SpecialChar.BACK - ), - button_type=None if auxiliary_style else 'backSmall', - on_activate_call=self.main_window_back, - ) - - if uiscale is bui.UIScale.SMALL: - self._back_button.delete() - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) - else: - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - - if ( - app.classic.platform in ['mac', 'ios'] - and app.classic.subplatform == 'appstore' - ): - bui.buttonwidget( - parent=self._root_widget, - id=f'{self.main_window_id_prefix}|restorepurchases', - position=(self._width * 0.5 - 70, 16), - size=(230, 50), - scale=0.65, - on_activate_call=bui.WeakCall(self._restore_purchases), - color=(0.35, 0.3, 0.4), - selectable=False, - textcolor=(0.55, 0.5, 0.6), - label=bui.Lstr( - resource='getTicketsWindow.restorePurchasesText' - ), - ) - - bui.textwidget( - parent=self._root_widget, - position=( - ( - self._width * 0.5 - + ( - (self._scroll_width * -0.5 + 90.0) - if uiscale is bui.UIScale.SMALL - else 0.0 - ) - ), - yoffs - (62 if uiscale is bui.UIScale.SMALL else -3.0), - ), - size=(0, 0), - color=app.ui_v1.title_color, - scale=1.1 if uiscale is bui.UIScale.SMALL else 1.3, - h_align='left' if uiscale is bui.UIScale.SMALL else 'center', - v_align='center', - text=bui.Lstr(resource='storeText'), - maxwidth=100 if uiscale is bui.UIScale.SMALL else 290, - ) - - tabs_def = [ - # (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), - (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), - ( - self.TabID.MINIGAMES, - bui.Lstr(resource=f'{self._r}.miniGamesText'), - ), - ( - self.TabID.CHARACTERS, - bui.Lstr(resource=f'{self._r}.charactersText'), - ), - (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), - ] - - tab_inset = 200 if uiscale is bui.UIScale.SMALL else 100 - self._tab_row = TabRow( - self._root_widget, - tabs_def, - idprefix=self.main_window_id_prefix, - size=(self._scroll_width - 2.0 * tab_inset, 50), - pos=( - self._width * 0.5 - self._scroll_width * 0.5 + tab_inset, - self._scroll_bottom + self._scroll_height - 4.0, - ), - on_select_call=self._set_tab, - ) - - self._purchasable_count_widgets: dict[ - StoreBrowserWindow.TabID, dict[str, Any] - ] = {} - - # Create our purchasable-items tags and have them update over time. - for tab_id, tab in self._tab_row.tabs.items(): - pos = tab.position - size = tab.size - button = tab.button - rad = 10 - center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) - img = bui.imagewidget( - parent=self._root_widget, - position=(center[0] - rad * 1.1, center[1] - rad * 1.2), - size=(rad * 2.4, rad * 2.4), - texture=bui.gettexture('circleShadow'), - color=(1, 0, 0), - ) - txt = bui.textwidget( - parent=self._root_widget, - position=center, - size=(0, 0), - h_align='center', - v_align='center', - maxwidth=1.4 * rad, - scale=0.6, - shadow=1.0, - flatness=1.0, - ) - rad = 20 - sale_img = bui.imagewidget( - parent=self._root_widget, - position=(center[0] - rad, center[1] - rad), - size=(rad * 2, rad * 2), - draw_controller=button, - texture=bui.gettexture('circleZigZag'), - color=(0.5, 0, 1.0), - ) - sale_title_text = bui.textwidget( - parent=self._root_widget, - position=(center[0], center[1] + 0.24 * rad), - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=button, - maxwidth=1.4 * rad, - scale=0.6, - shadow=0.0, - flatness=1.0, - color=(0, 1, 0), - ) - sale_time_text = bui.textwidget( - parent=self._root_widget, - position=(center[0], center[1] - 0.29 * rad), - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=button, - maxwidth=1.4 * rad, - scale=0.4, - shadow=0.0, - flatness=1.0, - color=(0, 1, 0), - ) - self._purchasable_count_widgets[tab_id] = { - 'img': img, - 'text': txt, - 'sale_img': sale_img, - 'sale_title_text': sale_title_text, - 'sale_time_text': sale_time_text, - } - self._tab_update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update_tabs), repeat=True - ) - self._update_tabs() - - if uiscale is bui.UIScale.SMALL: - first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button - last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button - bui.widget( - edit=first_tab_button, - left_widget=bui.get_special_widget('back_button'), - up_widget=bui.get_special_widget('back_button'), - ) - bui.widget( - edit=last_tab_button, - up_widget=bui.get_special_widget('tickets_meter'), - right_widget=bui.get_special_widget('tickets_meter'), - ) - - self._scrollwidget: bui.Widget | None = None - self._status_textwidget: bui.Widget | None = None - - # Restore/set tab. - try: - current_tab = self.TabID(bui.app.config.get('Store Tab')) - except ValueError: - current_tab = self.TabID.CHARACTERS - self._set_tab(current_tab) - - def _restore_purchases(self) -> None: - from bauiv1lib.account.signin import show_sign_in_prompt - - plus = bui.app.plus - assert plus is not None - if plus.accounts.primary is None: - show_sign_in_prompt() - else: - plus.restore_purchases() - - def _update_tabs(self) -> None: - assert bui.app.classic is not None - store = bui.app.classic.store - - if not self._root_widget: - return - for tab_id, tab_data in list(self._purchasable_count_widgets.items()): - sale_time = store.get_available_sale_time(tab_id.value) - - if sale_time is not None: - bui.textwidget( - edit=tab_data['sale_title_text'], - text=bui.Lstr(resource='store.saleText'), - ) - bui.textwidget( - edit=tab_data['sale_time_text'], - text=bui.timestring(sale_time / 1000.0, centi=False), - ) - bui.imagewidget(edit=tab_data['sale_img'], opacity=1.0) - count = 0 - else: - bui.textwidget(edit=tab_data['sale_title_text'], text='') - bui.textwidget(edit=tab_data['sale_time_text'], text='') - bui.imagewidget(edit=tab_data['sale_img'], opacity=0.0) - count = store.get_available_purchase_count(tab_id.value) - - if count > 0: - bui.textwidget(edit=tab_data['text'], text=str(count)) - bui.imagewidget(edit=tab_data['img'], opacity=1.0) - else: - bui.textwidget(edit=tab_data['text'], text='') - bui.imagewidget(edit=tab_data['img'], opacity=0.0) - - def _set_tab(self, tab_id: TabID) -> None: - if self._current_tab is tab_id: - return - self._current_tab = tab_id - - # We wanna preserve our current tab between runs. - cfg = bui.app.config - cfg['Store Tab'] = tab_id.value - cfg.commit() - - # Update tab colors based on which is selected. - self._tab_row.update_appearance(tab_id) - - # (Re)create scroll widget. - if self._scrollwidget: - self._scrollwidget.delete() - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - self._scroll_bottom, - ), - claims_left_right=True, - selection_loops_to_parent=True, - border_opacity=0.4, - ) - - # NOTE: this stuff is modified by the _Store class. Should maybe - # clean that up. - self.button_infos = {} - self.update_buttons_timer = None - - # Show status over top. - if self._status_textwidget: - self._status_textwidget.delete() - self._status_textwidget = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.5), - size=(0, 0), - color=(1, 0.7, 1, 0.5), - h_align='center', - v_align='center', - text=bui.Lstr(resource=f'{self._r}.loadingText'), - maxwidth=self._scroll_width * 0.9, - ) - - # Kick off a server request. - self.request = _Request(self, tab_id) - - # Actually start the purchase locally. - def _purchase_check_result( - self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None - ) -> None: - plus = bui.app.plus - assert plus is not None - if result is None: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='internal.unavailableNoConnectionText'), - color=(1, 0, 0), - ) - else: - if is_ticket_purchase: - if result['allow']: - price = plus.get_v1_account_misc_read_val( - 'price.' + item, None - ) - if ( - price is None - or not isinstance(price, int) - or price <= 0 - ): - print( - 'Error; got invalid local price of', - price, - 'for item', - item, - ) - bui.getsound('error').play() - else: - bui.getsound('click01').play() - plus.in_game_purchase(item, price) - else: - if result['reason'] == 'versionTooOld': - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.versionTooOldText' - ), - color=(1, 0, 0), - ) - else: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.unavailableText' - ), - color=(1, 0, 0), - ) - # Real in-app purchase. - else: - if result['allow']: - plus.purchase(item) - else: - if result['reason'] == 'versionTooOld': - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.versionTooOldText' - ), - color=(1, 0, 0), - ) - else: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.unavailableText' - ), - color=(1, 0, 0), - ) - - def _do_purchase_check( - self, item: str, is_ticket_purchase: bool = False - ) -> None: - app = bui.app - if app.classic is None: - logging.warning('_do_purchase_check() requires classic.') - return - - # Here we ping the server to ask if it's valid for us to - # purchase this. Better to fail now than after we've - # paid locally. - - app.classic.master_server_v1_get( - 'bsAccountPurchaseCheck', - { - 'item': item, - 'platform': app.classic.platform, - 'subplatform': app.classic.subplatform, - 'version': app.env.engine_version, - 'buildNumber': app.env.engine_build_number, - 'purchaseType': 'ticket' if is_ticket_purchase else 'real', - }, - callback=bui.WeakCall( - self._purchase_check_result, item, is_ticket_purchase - ), - ) - - def buy(self, item: str) -> None: - """Attempt to purchase the provided item.""" - from bauiv1lib.account.signin import show_sign_in_prompt - from bauiv1lib.confirm import ConfirmWindow - - assert bui.app.classic is not None - store = bui.app.classic.store - - plus = bui.app.plus - assert plus is not None - classic = bui.app.classic - assert classic is not None - - # Prevent pressing buy within a few seconds of the last press - # (gives the buttons time to disable themselves and whatnot). - curtime = bui.apptime() - if ( - self._last_buy_time is not None - and (curtime - self._last_buy_time) < 2.0 - ): - bui.getsound('error').play() - else: - if plus.accounts.primary is None: - show_sign_in_prompt() - else: - self._last_buy_time = curtime - - # Merch is a special case - just a link. - if item == 'merch': - url = bui.app.config.get('Merch Link') - if isinstance(url, str): - bui.open_url(url) - - # Pro is an actual IAP, and the rest are ticket purchases. - elif item == 'pro': - bui.getsound('click01').play() - - # Purchase either pro or pro_sale depending on whether - # there is a sale going on. - self._do_purchase_check( - 'pro' - if store.get_available_sale_time('extras') is None - else 'pro_sale' - ) - else: - price = plus.get_v1_account_misc_read_val( - 'price.' + item, None - ) - our_tickets = classic.tickets - if price is not None and our_tickets < price: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='notEnoughTicketsText'), - color=(1, 0, 0), - ) - # gettickets.show_get_tickets_prompt() - else: - - def do_it() -> None: - self._do_purchase_check( - item, is_ticket_purchase=True - ) - - bui.getsound('swish').play() - ConfirmWindow( - bui.Lstr( - resource='store.purchaseConfirmText', - subs=[ - ( - '${ITEM}', - store.get_store_item_name_translated( - item - ), - ) - ], - ), - width=400, - height=120, - action=do_it, - ok_text=bui.Lstr( - resource='store.purchaseText', - fallback_resource='okText', - ), - ) - - def _print_already_own(self, charname: str) -> None: - bui.screenmessage( - bui.Lstr( - resource=f'{self._r}.alreadyOwnText', - subs=[('${NAME}', charname)], - ), - color=(1, 0, 0), - ) - bui.getsound('error').play() - - def update_buttons(self) -> None: - """Update our buttons.""" - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - from bauiv1 import SpecialChar - - assert bui.app.classic is not None - store = bui.app.classic.store - - plus = bui.app.plus - assert plus is not None - classic = bui.app.classic - assert classic is not None - - if not self._root_widget: - return - - sales_raw = plus.get_v1_account_misc_read_val('sales', {}) - sales = {} - try: - # Look at the current set of sales; filter any with time remaining. - for sale_item, sale_info in list(sales_raw.items()): - to_end = ( - datetime.datetime.fromtimestamp( - sale_info['e'], datetime.UTC - ) - - utc_now() - ).total_seconds() - if to_end > 0: - sales[sale_item] = { - 'to_end': to_end, - 'original_price': sale_info['op'], - } - except Exception: - logging.exception('Error parsing sales.') - - assert self.button_infos is not None - for b_type, b_info in self.button_infos.items(): - if b_type == 'merch': - purchased = False - elif b_type in ['upgrades.pro', 'pro']: - assert bui.app.classic is not None - purchased = bui.app.classic.accounts.have_pro() - else: - assert bui.app.classic is not None - purchased = b_type in bui.app.classic.purchases - - sale_opacity = 0.0 - sale_title_text: str | bui.Lstr = '' - sale_time_text: str | bui.Lstr = '' - - call: Callable | None - if purchased: - title_color = (0.8, 0.7, 0.9, 1.0) - color = (0.63, 0.55, 0.78) - extra_image_opacity = 0.5 - call = bui.WeakCall(self._print_already_own, b_info['name']) - price_text = '' - price_text_left = '' - price_text_right = '' - show_purchase_check = True - description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) - description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) - price_color = (0.5, 1, 0.5, 0.3) - else: - title_color = (0.7, 0.9, 0.7, 1.0) - color = (0.4, 0.8, 0.1) - extra_image_opacity = 1.0 - call = b_info['call'] if 'call' in b_info else None - if b_type == 'merch': - price_text = '' - price_text_left = '' - price_text_right = '' - elif b_type in ['upgrades.pro', 'pro']: - sale_time = store.get_available_sale_time('extras') - if sale_time is not None: - priceraw = plus.get_price('pro') - price_text_left = ( - priceraw if priceraw is not None else '?' - ) - priceraw = plus.get_price('pro_sale') - price_text_right = ( - priceraw if priceraw is not None else '?' - ) - sale_opacity = 1.0 - price_text = '' - sale_title_text = bui.Lstr(resource='store.saleText') - sale_time_text = bui.timestring( - sale_time / 1000.0, centi=False - ) - else: - priceraw = plus.get_price('pro') - price_text = priceraw if priceraw is not None else '?' - price_text_left = '' - price_text_right = '' - else: - price = plus.get_v1_account_misc_read_val( - 'price.' + b_type, 0 - ) - - # Color the button differently if we cant afford this. - if plus.accounts.primary is not None: - if classic.tickets < price: - color = (0.6, 0.61, 0.6) - price_text = bui.charstr(bui.SpecialChar.TICKET) + str( - plus.get_v1_account_misc_read_val( - 'price.' + b_type, '?' - ) - ) - price_text_left = '' - price_text_right = '' - - # TESTING: - if b_type in sales: - sale_opacity = 1.0 - price_text_left = bui.charstr(SpecialChar.TICKET) + str( - sales[b_type]['original_price'] - ) - price_text_right = price_text - price_text = '' - sale_title_text = bui.Lstr(resource='store.saleText') - sale_time_text = bui.timestring( - sales[b_type]['to_end'], centi=False - ) - - description_color = (0.5, 1.0, 0.5) - description_color2 = (0.3, 1.0, 1.0) - price_color = (0.2, 1, 0.2, 1.0) - show_purchase_check = False - - if 'title_text' in b_info: - bui.textwidget(edit=b_info['title_text'], color=title_color) - if 'purchase_check' in b_info: - bui.imagewidget( - edit=b_info['purchase_check'], - opacity=1.0 if show_purchase_check else 0.0, - ) - if 'price_widget' in b_info: - bui.textwidget( - edit=b_info['price_widget'], - text=price_text, - color=price_color, - ) - if 'price_widget_left' in b_info: - bui.textwidget( - edit=b_info['price_widget_left'], text=price_text_left - ) - if 'price_widget_right' in b_info: - bui.textwidget( - edit=b_info['price_widget_right'], text=price_text_right - ) - if 'price_slash_widget' in b_info: - bui.imagewidget( - edit=b_info['price_slash_widget'], opacity=sale_opacity - ) - if 'sale_bg_widget' in b_info: - bui.imagewidget( - edit=b_info['sale_bg_widget'], opacity=sale_opacity - ) - if 'sale_title_widget' in b_info: - bui.textwidget( - edit=b_info['sale_title_widget'], text=sale_title_text - ) - if 'sale_time_widget' in b_info: - bui.textwidget( - edit=b_info['sale_time_widget'], text=sale_time_text - ) - if 'button' in b_info: - bui.buttonwidget( - edit=b_info['button'], color=color, on_activate_call=call - ) - if 'extra_backings' in b_info: - for bck in b_info['extra_backings']: - bui.imagewidget( - edit=bck, color=color, opacity=extra_image_opacity - ) - if 'extra_images' in b_info: - for img in b_info['extra_images']: - bui.imagewidget(edit=img, opacity=extra_image_opacity) - if 'extra_texts' in b_info: - for etxt in b_info['extra_texts']: - bui.textwidget(edit=etxt, color=description_color) - if 'extra_texts_2' in b_info: - for etxt in b_info['extra_texts_2']: - bui.textwidget(edit=etxt, color=description_color2) - if 'descriptionText' in b_info: - bui.textwidget( - edit=b_info['descriptionText'], color=description_color - ) - - def _on_response(self, data: dict[str, Any] | None) -> None: - - # clear status text.. - if self._status_textwidget: - self._status_textwidget.delete() - self._status_textwidget_update_timer = None - - if data is None: - self._status_textwidget = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.5), - size=(0, 0), - scale=1.3, - transition_delay=0.1, - color=(1, 0.3, 0.3, 1.0), - h_align='center', - v_align='center', - text=bui.Lstr(resource=f'{self._r}.loadErrorText'), - maxwidth=self._scroll_width * 0.9, - ) - else: - - if self._current_tab in ( - # self.TabID.EXTRAS, - self.TabID.MINIGAMES, - self.TabID.CHARACTERS, - self.TabID.MAPS, - self.TabID.ICONS, - ): - store = _Store(self, data, self._scroll_width) - assert self._scrollwidget is not None - store.instantiate( - scrollwidget=self._scrollwidget, - tab_button=self._tab_row.tabs[self._current_tab].button, - ) - # Most of our UI won't exist until this point so we need - # to explicitly restore state for selection restore to - # work. - # - # Note to self: perhaps we should *not* do this if - # significant time has passed since the window was made - # or if input commands have happened. - self.main_window_restore_shared_state() - - else: - cnt = bui.containerwidget( - parent=self._scrollwidget, - scale=1.0, - size=(self._scroll_width, self._scroll_height * 0.95), - background=False, - claims_left_right=True, - selection_loops_to_parent=True, - ) - self._status_textwidget = bui.textwidget( - parent=cnt, - position=( - self._scroll_width * 0.5, - self._scroll_height * 0.5, - ), - size=(0, 0), - scale=1.3, - transition_delay=0.1, - color=(1, 1, 0.3, 1.0), - h_align='center', - v_align='center', - text=bui.Lstr(resource=f'{self._r}.comingSoonText'), - maxwidth=self._scroll_width * 0.9, - ) - - @override - def get_main_window_state(self) -> bui.MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - return bui.BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - transition=transition, origin_widget=origin_widget - ) - ) - - @override - def main_window_should_preserve_selection(self) -> bool: - return True - - -def _check_merch_availability_in_bg_thread() -> None: - # pylint: disable=cell-var-from-loop - - # Merch is available from some countries only. Make a reasonable - # check to ask the master-server about this at launch and store the - # results. - plus = bui.app.plus - assert plus is not None - - for _i in range(15): - try: - if plus.cloud.is_connected(): - response = plus.cloud.send_message( - bacommon.cloud.MerchAvailabilityMessage() - ) - - def _store_in_logic_thread() -> None: - cfg = bui.app.config - current = cfg.get(MERCH_LINK_KEY) - if not isinstance(current, str | None): - current = None - if current != response.url: - cfg[MERCH_LINK_KEY] = response.url - cfg.commit() - - # If we successfully get a response, kick it over to the - # logic thread to store and we're done. - bui.pushcall(_store_in_logic_thread, from_other_thread=True) - return - except CommunicationError: - pass - except Exception: - logging.warning( - 'Unexpected error in merch-availability-check.', exc_info=True - ) - time.sleep(1.1934) # A bit randomized to avoid aliasing. - - -class _Store: - def __init__( - self, - store_window: StoreBrowserWindow, - sdata: dict[str, Any], - width: float, - ): - assert bui.app.classic is not None - cstore = bui.app.classic.store - - self._store_window = store_window - self._width = width - store_data = cstore.get_store_layout() - self._tab = sdata['tab'] - self._sections = copy.deepcopy(store_data[sdata['tab']]) - self._height: float | None = None - - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - - # Pre-calc a few things and add them to store-data. - for section in self._sections: - if self._tab == 'characters': - dummy_name = 'characters.foo' - elif self._tab == 'extras': - dummy_name = 'pro' - elif self._tab == 'maps': - dummy_name = 'maps.foo' - elif self._tab == 'icons': - dummy_name = 'icons.foo' - else: - dummy_name = '' - section['button_size'] = cstore.get_store_item_display_size( - dummy_name - ) - section['v_spacing'] = ( - -25 - if (self._tab == 'extras' and uiscale is bui.UIScale.SMALL) - else -17 if self._tab == 'characters' else 0 - ) - if 'title' not in section: - section['title'] = '' - section['x_offs'] = 0.0 - # section['x_offs'] = ( - # 130 - # if self._tab == 'extras' - # else 270 if self._tab == 'maps' else 0 - # ) - section['y_offs'] = ( - 20 - if ( - self._tab == 'extras' - and uiscale is bui.UIScale.SMALL - and bui.app.config.get('Merch Link') - ) - else ( - 55 - if (self._tab == 'extras' and uiscale is bui.UIScale.SMALL) - else -20 if self._tab == 'icons' else 0 - ) - ) - - def instantiate( - self, scrollwidget: bui.Widget, tab_button: bui.Widget - ) -> None: - """Create the store.""" - # pylint: disable=too-many-statements - # pylint: disable=too-many-locals - # pylint: disable=too-many-branches - # pylint: disable=too-many-nested-blocks - from bauiv1lib.store.item import ( - instantiate_store_item_display, - ) - - title_spacing = 40 - button_border = 20 - button_spacing = 4 - boffs_h = 0.0 - self._height = 80.0 - - # Calc total height. - for i, section in enumerate(self._sections): - if section['title'] != '': - assert self._height is not None - self._height += title_spacing - b_width, b_height = section['button_size'] - b_count = len(section['items']) - b_column_count = min( - b_count, - int(math.floor(self._width / (b_width + button_spacing))), - ) - b_row_count = int(math.ceil(b_count / b_column_count)) - b_height_total = ( - 2 * button_border - + b_row_count * b_height - + (b_row_count - 1) * section['v_spacing'] - ) - self._height += b_height_total - - assert self._height is not None - cnt2 = bui.containerwidget( - parent=scrollwidget, - scale=1.0, - size=(self._width, self._height), - background=False, - claims_left_right=True, - selection_loops_to_parent=True, - ) - v = self._height - 20 - - if self._tab == 'characters': - txt = bui.Lstr( - resource='store.howToSwitchCharactersText', - subs=[ - ( - '${SETTINGS}', - bui.Lstr(resource='inventoryText'), - ), - ( - '${PLAYER_PROFILES}', - bui.Lstr(resource='playerProfilesWindow.titleText'), - ), - ], - ) - bui.textwidget( - parent=cnt2, - text=txt, - size=(0, 0), - position=(self._width * 0.5, self._height - 28), - h_align='center', - v_align='center', - color=(0.7, 1, 0.7, 0.4), - scale=0.7, - shadow=0, - flatness=1.0, - maxwidth=700, - transition_delay=0.4, - ) - elif self._tab == 'icons': - txt = bui.Lstr( - resource='store.howToUseIconsText', - subs=[ - ( - '${SETTINGS}', - bui.Lstr(resource='mainMenu.settingsText'), - ), - ( - '${PLAYER_PROFILES}', - bui.Lstr(resource='playerProfilesWindow.titleText'), - ), - ], - ) - bui.textwidget( - parent=cnt2, - text=txt, - size=(0, 0), - position=(self._width * 0.5, self._height - 28), - h_align='center', - v_align='center', - color=(0.7, 1, 0.7, 0.4), - scale=0.7, - shadow=0, - flatness=1.0, - maxwidth=700, - transition_delay=0.4, - ) - elif self._tab == 'maps': - assert self._width is not None - assert self._height is not None - txt = bui.Lstr(resource='store.howToUseMapsText') - bui.textwidget( - parent=cnt2, - text=txt, - size=(0, 0), - position=(self._width * 0.5, self._height - 28), - h_align='center', - v_align='center', - color=(0.7, 1, 0.7, 0.4), - scale=0.7, - shadow=0, - flatness=1.0, - maxwidth=700, - transition_delay=0.4, - ) - - prev_row_buttons: list | None = None - this_row_buttons = [] - - delay = 0.3 - for section in self._sections: - if section['title'] != '': - bui.textwidget( - parent=cnt2, - position=( - self._width * 0.5, - v - title_spacing * 0.8, - ), - size=(0, 0), - scale=1.0, - transition_delay=delay, - color=(0.7, 0.9, 0.7, 1), - h_align='center', - v_align='center', - text=bui.Lstr(resource=section['title']), - maxwidth=self._width * 0.7, - ) - v -= title_spacing - delay = max(0.100, delay - 0.100) - v -= button_border - b_width, b_height = section['button_size'] - b_count = len(section['items']) - b_column_count = min( - b_count, - int(math.floor(self._width / (b_width + button_spacing))), - ) - - col = 0 - item: dict[str, Any] - assert self._store_window.button_infos is not None - for i, item_name in enumerate(section['items']): - item = self._store_window.button_infos[item_name] = {} - item['call'] = bui.WeakCall(self._store_window.buy, item_name) - boffs_h2 = section.get('x_offs', 0.0) - boffs_v2 = section.get('y_offs', 0.0) - - # Calc the diff between the space we use and - # the space available and nudge us right by - # half that to center things. - boffs_h2 += 0.5 * ( - self._width - ((b_width + button_spacing) * b_column_count) - ) - - b_pos = ( - boffs_h + boffs_h2 + (b_width + button_spacing) * col, - v - b_height + boffs_v2, - ) - instantiate_store_item_display( - item_name, - item, - idprefix=self._store_window.main_window_id_prefix, - parent_widget=cnt2, - b_pos=b_pos, - boffs_h=boffs_h, - b_width=b_width, - b_height=b_height, - boffs_h2=boffs_h2, - boffs_v2=boffs_v2, - delay=delay, - ) - btn = item['button'] - delay = max(0.1, delay - 0.1) - this_row_buttons.append(btn) - - # Wire this button to the equivalent in the - # previous row. - if prev_row_buttons is not None: - if len(prev_row_buttons) > col: - bui.widget( - edit=btn, - up_widget=prev_row_buttons[col], - ) - bui.widget( - edit=prev_row_buttons[col], - down_widget=btn, - ) - - # If we're the last button in our row, - # wire any in the previous row past - # our position to go to us if down is - # pressed. - if col + 1 == b_column_count or i == b_count - 1: - for b_prev in prev_row_buttons[col + 1 :]: - bui.widget(edit=b_prev, down_widget=btn) - else: - bui.widget(edit=btn, up_widget=prev_row_buttons[-1]) - else: - bui.widget(edit=btn, up_widget=tab_button) - - col += 1 - if col == b_column_count or i == b_count - 1: - prev_row_buttons = this_row_buttons - this_row_buttons = [] - col = 0 - v -= b_height - if i < b_count - 1: - v -= section['v_spacing'] - - v -= button_border - - # Set a timer to update these buttons periodically - # as long as we're alive (so if we buy one it will - # grey out, etc). - self._store_window.update_buttons_timer = bui.AppTimer( - 0.5, - bui.WeakCall(self._store_window.update_buttons), - repeat=True, - ) - - # Also update them immediately. - self._store_window.update_buttons() - - -class _Request: - def __init__( - self, window: StoreBrowserWindow, tab_id: StoreBrowserWindow.TabID - ): - self._window = weakref.ref(window) - data = {'tab': tab_id.value} - bui.apptimer(0.1, bui.WeakCall(self._on_response, data)) - - def _on_response(self, data: dict[str, Any] | None) -> None: - # FIXME: clean this up. - # pylint: disable=protected-access - window = self._window() - if window is not None and (window.request is self): - window.request = None - window._on_response(data) - - -# Slight hack; start checking merch availability in the bg (but only if -# it looks like we've been imported for use in a running app; don't want -# to do this during docs generation/etc.) - -# NOTE: Disabling this for now since we're not showing the merch section -# (and want to purge all use of daemon threads). - -# TODO: Should wire this up explicitly to app bootstrapping; not good to -# be kicking off work at module import time. -if ( - bool(False) - and os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1' - and bui.app.state is not bui.AppState.NOT_STARTED -): - Thread(target=_check_merch_availability_in_bg_thread).start() diff --git a/dist/ba_data/python/bauiv1lib/store/item.py b/dist/ba_data/python/bauiv1lib/store/item.py deleted file mode 100644 index f598256..0000000 --- a/dist/ba_data/python/bauiv1lib/store/item.py +++ /dev/null @@ -1,729 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality related to UI items.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -import bascenev1 as bs -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -def instantiate_store_item_display( - item_name: str, - item: dict[str, Any], - *, - parent_widget: bui.Widget, - b_pos: tuple[float, float], - b_width: float, - b_height: float, - idprefix: str, - boffs_h: float = 0.0, - boffs_h2: float = 0.0, - boffs_v2: float = 0, - delay: float = 0.0, - button: bool = True, -) -> None: - """(internal)""" - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - assert bui.app.classic is not None - store = bui.app.classic.store - - plus = bui.app.plus - assert plus is not None - - del boffs_h # unused arg - del boffs_h2 # unused arg - del boffs_v2 # unused arg - item_info = store.get_store_item(item_name) - title_v = 0.24 - price_v = 0.145 - base_text_scale = 1.0 - - item['name'] = title = store.get_store_item_name_translated(item_name) - - btn: bui.Widget | None - - # Hack; showbuffer stuff isn't working well when we're showing merch. - showbuffer = 10 if item_name in {'merch', 'pro', 'pro_sale'} else 76.0 - - if button: - item['button'] = btn = bui.buttonwidget( - parent=parent_widget, - id=f'{idprefix}|store_item.{item_name}', - position=b_pos, - transition_delay=delay, - show_buffer_top=showbuffer, - enable_sound=False, - button_type='square', - size=(b_width, b_height), - autoselect=True, - label='', - ) - bui.widget(edit=btn, show_buffer_bottom=showbuffer) - else: - btn = None - - b_offs_x = -0.015 * b_width - check_pos = 0.76 - - icon_tex = None - tint_tex = None - tint_color = None - tint2_color = None - tex_name: str | None = None - desc: bui.Lstr | None = None - modes: bui.Lstr | None = None - - if item_name.startswith('characters.'): - assert bui.app.classic is not None - character = bui.app.classic.spaz_appearances[item_info['character']] - tint_color = ( - item_info['color'] - if 'color' in item_info - else ( - character.default_color - if character.default_color is not None - else (1, 1, 1) - ) - ) - tint2_color = ( - item_info['highlight'] - if 'highlight' in item_info - else ( - character.default_highlight - if character.default_highlight is not None - else (1, 1, 1) - ) - ) - icon_tex = character.icon_texture - tint_tex = character.icon_mask_texture - title_v = 0.255 - price_v = 0.145 - elif item_name == 'merch': - base_text_scale = 0.6 - title_v = 0.85 - price_v = 0.15 - elif item_name in ['upgrades.pro', 'pro']: - base_text_scale = 0.6 - title_v = 0.85 - price_v = 0.15 - elif item_name.startswith('maps.'): - map_type = item_info['map_type'] - tex_name = map_type.get_preview_texture_name() - title_v = 0.312 - price_v = 0.17 - - elif item_name.startswith('games.'): - gametype = item_info['gametype'] - modes_l = [] - if gametype.supports_session_type(bs.CoopSession): - modes_l.append(bui.Lstr(resource='playModes.coopText')) - if gametype.supports_session_type(bs.DualTeamSession): - modes_l.append(bui.Lstr(resource='playModes.teamsText')) - if gametype.supports_session_type(bs.FreeForAllSession): - modes_l.append(bui.Lstr(resource='playModes.freeForAllText')) - - if len(modes_l) == 3: - modes = bui.Lstr( - value='${A}, ${B}, ${C}', - subs=[ - ('${A}', modes_l[0]), - ('${B}', modes_l[1]), - ('${C}', modes_l[2]), - ], - ) - elif len(modes_l) == 2: - modes = bui.Lstr( - value='${A}, ${B}', - subs=[('${A}', modes_l[0]), ('${B}', modes_l[1])], - ) - elif len(modes_l) == 1: - modes = modes_l[0] - else: - raise RuntimeError() - desc = gametype.get_description_display_string(bs.CoopSession) - tex_name = item_info['previewTex'] - base_text_scale = 0.8 - title_v = 0.48 - price_v = 0.17 - elif item_name == 'upgrades.infinite_runaround': - base_text_scale = 0.8 - desc = bui.Lstr( - translate=( - 'gameDescriptions', - 'Prevent enemies from reaching the exit.', - ) - ) - modes = bui.Lstr(resource='playModes.coopText') - tex_name = 'towerDPreview' - title_v = 0.48 - price_v = 0.17 - elif item_name == 'upgrades.infinite_onslaught': - base_text_scale = 0.8 - desc = bui.Lstr( - translate=( - 'gameDescriptions', - 'Defeat all enemies.', - ) - ) - modes = bui.Lstr(resource='playModes.coopText') - tex_name = 'doomShroomPreview' - title_v = 0.48 - price_v = 0.17 - - elif item_name.startswith('icons.'): - base_text_scale = 1.5 - price_v = 0.2 - check_pos = 0.6 - - if item_name.startswith('characters.'): - frame_size = b_width * 0.7 - im_dim = frame_size * (100.0 / 113.0) - im_pos = ( - b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.57 - im_dim * 0.5, - ) - mask_texture = bui.gettexture('characterIconMask') - assert icon_tex is not None - assert tint_tex is not None - bui.imagewidget( - parent=parent_widget, - position=im_pos, - size=(im_dim, im_dim), - color=(1, 1, 1), - transition_delay=delay, - mask_texture=mask_texture, - draw_controller=btn, - texture=bui.gettexture(icon_tex), - tint_texture=bui.gettexture(tint_tex), - tint_color=tint_color, - tint2_color=tint2_color, - ) - - if item_name == 'merch': - frame_size = b_width * 0.65 - im_dim = frame_size * (100.0 / 113.0) - im_pos = ( - b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.47 - im_dim * 0.5, - ) - bui.imagewidget( - parent=parent_widget, - position=im_pos, - size=(im_dim, im_dim), - transition_delay=delay, - draw_controller=btn, - opacity=1.0, - texture=bui.gettexture('merch'), - ) - - if item_name in ['pro', 'upgrades.pro']: - frame_size = b_width * 0.5 - im_dim = frame_size * (100.0 / 113.0) - im_pos = ( - b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.5 - im_dim * 0.5, - ) - bui.imagewidget( - parent=parent_widget, - position=im_pos, - size=(im_dim, im_dim), - transition_delay=delay, - draw_controller=btn, - color=(0.3, 0.0, 0.3), - opacity=0.3, - texture=bui.gettexture('logo'), - ) - txt = bui.Lstr(resource='store.bombSquadProNewDescriptionText') - - item['descriptionText'] = bui.textwidget( - parent=parent_widget, - text=txt, - position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.69), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale * 0.75, - maxwidth=b_width * 0.75, - max_height=b_height * 0.2, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - color=(0.3, 1, 0.3), - ) - - extra_backings = item['extra_backings'] = [] - extra_images = item['extra_images'] = [] - extra_texts = item['extra_texts'] = [] - extra_texts_2 = item['extra_texts_2'] = [] - - backing_color = (0.5, 0.8, 0.3) if button else (0.6, 0.5, 0.65) - b_square_texture = bui.gettexture('buttonSquare') - char_mask_texture = bui.gettexture('characterIconMask') - - pos = (0.17, 0.43) - tile_size = (b_width * 0.16 * 1.2, b_width * 0.2 * 1.2) - tile_pos = (b_pos[0] + b_width * pos[0], b_pos[1] + b_height * pos[1]) - extra_backings.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - tile_size[0] * 0.5, - tile_pos[1] - tile_size[1] * 0.5, - ), - size=tile_size, - transition_delay=delay, - draw_controller=btn, - color=backing_color, - texture=b_square_texture, - ) - ) - im_size = tile_size[0] * 0.8 - extra_images.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - im_size * 0.5, - tile_pos[1] - im_size * 0.4, - ), - size=(im_size, im_size), - transition_delay=delay, - draw_controller=btn, - color=(1, 1, 1), - texture=bui.gettexture('ticketsMore'), - ) - ) - bonus_tickets = str( - plus.get_v1_account_misc_read_val('proBonusTickets', 100) - ) - extra_texts.append( - bui.textwidget( - parent=parent_widget, - draw_controller=btn, - position=( - tile_pos[0] - tile_size[0] * 0.03, - tile_pos[1] - tile_size[1] * 0.25, - ), - size=(0, 0), - color=(0.6, 1, 0.6), - transition_delay=delay, - h_align='center', - v_align='center', - maxwidth=tile_size[0] * 0.7, - scale=0.55, - text=bui.Lstr( - resource='getTicketsWindow.ticketsText', - subs=[('${COUNT}', bonus_tickets)], - ), - flatness=1.0, - shadow=0.0, - ) - ) - - for charname, pos in [ - ('Kronk', (0.32, 0.45)), - ('Zoe', (0.425, 0.4)), - ('Jack Morgan', (0.555, 0.45)), - ('Mel', (0.645, 0.4)), - ]: - tile_size = (b_width * 0.16 * 0.9, b_width * 0.2 * 0.9) - tile_pos = ( - b_pos[0] + b_width * pos[0], - b_pos[1] + b_height * pos[1], - ) - assert bui.app.classic is not None - character = bui.app.classic.spaz_appearances[charname] - extra_backings.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - tile_size[0] * 0.5, - tile_pos[1] - tile_size[1] * 0.5, - ), - size=tile_size, - transition_delay=delay, - draw_controller=btn, - color=backing_color, - texture=b_square_texture, - ) - ) - im_size = tile_size[0] * 0.7 - extra_images.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - im_size * 0.53, - tile_pos[1] - im_size * 0.35, - ), - size=(im_size, im_size), - transition_delay=delay, - draw_controller=btn, - color=(1, 1, 1), - texture=bui.gettexture(character.icon_texture), - tint_texture=bui.gettexture(character.icon_mask_texture), - tint_color=character.default_color, - tint2_color=character.default_highlight, - mask_texture=char_mask_texture, - ) - ) - extra_texts.append( - bui.textwidget( - parent=parent_widget, - draw_controller=btn, - position=( - tile_pos[0] - im_size * 0.03, - tile_pos[1] - im_size * 0.51, - ), - size=(0, 0), - color=(0.6, 1, 0.6), - transition_delay=delay, - h_align='center', - v_align='center', - maxwidth=tile_size[0] * 0.7, - scale=0.55, - text=bui.Lstr(translate=('characterNames', charname)), - flatness=1.0, - shadow=0.0, - ) - ) - - # If we have a 'total-worth' item-id for this id, show that price so - # the user knows how much this is worth. - total_worth_item = plus.get_v1_account_misc_read_val('twrths', {}).get( - item_name - ) - total_worth_price: str | None - if total_worth_item is not None: - price = plus.get_price(total_worth_item) - total_worth_price = ( - store.get_clean_price(price) if price is not None else '??' - ) - else: - total_worth_price = None - - if total_worth_price is not None: - total_worth_text = bui.Lstr( - resource='store.totalWorthText', - subs=[('${TOTAL_WORTH}', total_worth_price)], - ) - extra_texts_2.append( - bui.textwidget( - parent=parent_widget, - text=total_worth_text, - position=( - b_pos[0] + b_width * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.25, - ), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale * 0.45, - maxwidth=b_width * 0.5, - size=(0, 0), - h_align='center', - v_align='center', - shadow=1.0, - flatness=1.0, - draw_controller=btn, - color=(0.3, 1, 1), - ) - ) - - mesh_opaque = bui.getmesh('level_select_button_opaque') - mesh_transparent = bui.getmesh('level_select_button_transparent') - mask_tex = bui.gettexture('mapPreviewMask') - for levelname, preview_tex_name, pos in [ - ('Infinite Onslaught', 'doomShroomPreview', (0.80, 0.48)), - ('Infinite Runaround', 'towerDPreview', (0.80, 0.32)), - ]: - tile_size = (b_width * 0.2, b_width * 0.13) - tile_pos = ( - b_pos[0] + b_width * pos[0], - b_pos[1] + b_height * pos[1], - ) - im_size = tile_size[0] * 0.8 - extra_backings.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - tile_size[0] * 0.5, - tile_pos[1] - tile_size[1] * 0.5, - ), - size=tile_size, - transition_delay=delay, - draw_controller=btn, - color=backing_color, - texture=b_square_texture, - ) - ) - - # Hack - gotta draw two transparent versions to avoid z issues. - for mod in mesh_opaque, mesh_transparent: - extra_images.append( - bui.imagewidget( - parent=parent_widget, - position=( - tile_pos[0] - im_size * 0.52, - tile_pos[1] - im_size * 0.2, - ), - size=(im_size, im_size * 0.5), - transition_delay=delay, - mesh_transparent=mod, - mask_texture=mask_tex, - draw_controller=btn, - texture=bui.gettexture(preview_tex_name), - ) - ) - - extra_texts.append( - bui.textwidget( - parent=parent_widget, - draw_controller=btn, - position=( - tile_pos[0] - im_size * 0.03, - tile_pos[1] - im_size * 0.2, - ), - size=(0, 0), - color=(0.6, 1, 0.6), - transition_delay=delay, - h_align='center', - v_align='center', - maxwidth=tile_size[0] * 0.7, - scale=0.55, - text=bui.Lstr(translate=('coopLevelNames', levelname)), - flatness=1.0, - shadow=0.0, - ) - ) - - if item_name.startswith('icons.'): - item['icon_text'] = bui.textwidget( - parent=parent_widget, - text=item_info['icon'], - position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.5), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale * 2.0, - maxwidth=b_width * 0.9, - max_height=b_height * 0.9, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - ) - - if item_name.startswith('maps.'): - frame_size = b_width * 0.9 - im_dim = frame_size * (100.0 / 113.0) - im_pos = ( - b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.62 - im_dim * 0.25, - ) - mesh_opaque = bui.getmesh('level_select_button_opaque') - mesh_transparent = bui.getmesh('level_select_button_transparent') - mask_tex = bui.gettexture('mapPreviewMask') - assert tex_name is not None - bui.imagewidget( - parent=parent_widget, - position=im_pos, - size=(im_dim, im_dim * 0.5), - transition_delay=delay, - mesh_opaque=mesh_opaque, - mesh_transparent=mesh_transparent, - mask_texture=mask_tex, - draw_controller=btn, - texture=bui.gettexture(tex_name), - ) - - if item_name.startswith('games.') or item_name in ( - 'upgrades.infinite_runaround', - 'upgrades.infinite_onslaught', - ): - frame_size = b_width * 0.8 - im_dim = frame_size * (100.0 / 113.0) - im_pos = ( - b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x, - b_pos[1] + b_height * 0.72 - im_dim * 0.25, - ) - mesh_opaque = bui.getmesh('level_select_button_opaque') - mesh_transparent = bui.getmesh('level_select_button_transparent') - mask_tex = bui.gettexture('mapPreviewMask') - assert tex_name is not None - bui.imagewidget( - parent=parent_widget, - position=im_pos, - size=(im_dim, im_dim * 0.5), - transition_delay=delay, - mesh_opaque=mesh_opaque, - mesh_transparent=mesh_transparent, - mask_texture=mask_tex, - draw_controller=btn, - texture=bui.gettexture(tex_name), - ) - item['descriptionText'] = bui.textwidget( - parent=parent_widget, - text=desc, - position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.36), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale * 0.78, - maxwidth=b_width * 0.8, - max_height=b_height * 0.14, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - flatness=1.0, - shadow=0.0, - color=(0.6, 1, 0.6), - ) - item['gameModesText'] = bui.textwidget( - parent=parent_widget, - text=modes, - position=(b_pos[0] + b_width * 0.5, b_pos[1] + b_height * 0.26), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale * 0.65, - maxwidth=b_width * 0.8, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - shadow=0, - flatness=1.0, - color=(0.6, 0.8, 0.6), - ) - - if not item_name.startswith('icons.'): - item['title_text'] = bui.textwidget( - parent=parent_widget, - text=title, - position=( - b_pos[0] + b_width * 0.5 + b_offs_x, - b_pos[1] + b_height * title_v, - ), - transition_delay=delay, - scale=b_width * (1.0 / 230.0) * base_text_scale, - maxwidth=b_width * 0.8, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - color=(0.7, 0.9, 0.7, 1.0), - ) - - item['purchase_check'] = bui.imagewidget( - parent=parent_widget, - position=(b_pos[0] + b_width * check_pos, b_pos[1] + b_height * 0.05), - transition_delay=delay, - mesh_transparent=bui.getmesh('checkTransparent'), - opacity=0.0, - size=(60, 60), - color=(0.6, 0.5, 0.8), - draw_controller=btn, - texture=bui.gettexture('uiAtlas'), - ) - item['price_widget'] = bui.textwidget( - parent=parent_widget, - text='', - position=( - b_pos[0] + b_width * 0.5 + b_offs_x, - b_pos[1] + b_height * price_v, - ), - transition_delay=delay, - scale=b_width * (1.0 / 300.0) * base_text_scale, - maxwidth=b_width * 0.9, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - color=(0.2, 1, 0.2, 1.0), - ) - item['price_widget_left'] = bui.textwidget( - parent=parent_widget, - text='', - position=( - b_pos[0] + b_width * 0.33 + b_offs_x, - b_pos[1] + b_height * price_v, - ), - transition_delay=delay, - scale=b_width * (1.0 / 300.0) * base_text_scale, - maxwidth=b_width * 0.3, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - color=(0.2, 1, 0.2, 0.5), - ) - item['price_widget_right'] = bui.textwidget( - parent=parent_widget, - text='', - position=( - b_pos[0] + b_width * 0.66 + b_offs_x, - b_pos[1] + b_height * price_v, - ), - transition_delay=delay, - scale=1.1 * b_width * (1.0 / 300.0) * base_text_scale, - maxwidth=b_width * 0.3, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - color=(0.2, 1, 0.2, 1.0), - ) - item['price_slash_widget'] = bui.imagewidget( - parent=parent_widget, - position=( - b_pos[0] + b_width * 0.33 + b_offs_x - 36, - b_pos[1] + b_height * price_v - 35, - ), - transition_delay=delay, - texture=bui.gettexture('slash'), - opacity=0.0, - size=(70, 70), - draw_controller=btn, - color=(1, 0, 0), - ) - badge_rad = 44 - badge_center = ( - b_pos[0] + b_width * 0.1 + b_offs_x, - b_pos[1] + b_height * 0.87, - ) - item['sale_bg_widget'] = bui.imagewidget( - parent=parent_widget, - position=(badge_center[0] - badge_rad, badge_center[1] - badge_rad), - opacity=0.0, - transition_delay=delay, - texture=bui.gettexture('circleZigZag'), - draw_controller=btn, - size=(badge_rad * 2, badge_rad * 2), - color=(0.5, 0, 1), - ) - item['sale_title_widget'] = bui.textwidget( - parent=parent_widget, - position=(badge_center[0], badge_center[1] + 12), - transition_delay=delay, - scale=1.0, - maxwidth=badge_rad * 1.6, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - shadow=0.0, - flatness=1.0, - color=(0, 1, 0), - ) - item['sale_time_widget'] = bui.textwidget( - parent=parent_widget, - position=(badge_center[0], badge_center[1] - 12), - transition_delay=delay, - scale=0.7, - maxwidth=badge_rad * 1.6, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn, - shadow=0.0, - flatness=1.0, - color=(0.0, 1, 0.0, 1), - ) diff --git a/dist/ba_data/python/bauiv1lib/tabs.py b/dist/ba_data/python/bauiv1lib/tabs.py index a3d298d..a4d85c3 100644 --- a/dist/ba_data/python/bauiv1lib/tabs.py +++ b/dist/ba_data/python/bauiv1lib/tabs.py @@ -62,7 +62,7 @@ class TabRow[T: Enum]: size=size, label=tab_label, enable_sound=False, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._tick_and_call, on_select_call, tab_id ), ) diff --git a/dist/ba_data/python/bauiv1lib/teamnamescolors.py b/dist/ba_data/python/bauiv1lib/teamnamescolors.py index 4e4622e..ed90fb7 100644 --- a/dist/ba_data/python/bauiv1lib/teamnamescolors.py +++ b/dist/ba_data/python/bauiv1lib/teamnamescolors.py @@ -73,7 +73,7 @@ class TeamNamesColorsWindow(PopupWindow): id=f'{self._idprefix}|colorbutton{i}', autoselect=True, position=(50, 0 + 195 - 90 * i), - on_activate_call=bui.Call(self._color_click, i), + on_activate_call=bui.CallStrict(self._color_click, i), size=(70, 70), color=self._colors[i], label='', diff --git a/dist/ba_data/python/bauiv1lib/template.py b/dist/ba_data/python/bauiv1lib/template.py index c9e25cb..9687d8e 100644 --- a/dist/ba_data/python/bauiv1lib/template.py +++ b/dist/ba_data/python/bauiv1lib/template.py @@ -212,7 +212,7 @@ class TemplateMainWindow(bui.MainWindow): autoselect=True, size=(button_width, 60), label=f'Template{child_dummy_data}', - on_activate_call=bui.WeakCall( + on_activate_call=bui.WeakCallStrict( self._child_press, child_dummy_data ), ) diff --git a/dist/ba_data/python/bauiv1lib/tournamententry.py b/dist/ba_data/python/bauiv1lib/tournamententry.py index b09cf5a..1717a53 100644 --- a/dist/ba_data/python/bauiv1lib/tournamententry.py +++ b/dist/ba_data/python/bauiv1lib/tournamententry.py @@ -7,9 +7,11 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, override -from bauiv1lib.popup import PopupWindow +from bacommon.analytics import ClassicAnalyticsEvent import bauiv1 as bui +from bauiv1lib.popup import PopupWindow + if TYPE_CHECKING: from typing import Any, Callable import bascenev1 as bs @@ -361,7 +363,7 @@ class TournamentEntryWindow(PopupWindow): self._fg_state = bui.app.fg_state self._running_query = False self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) self._update() self._restore_state() @@ -433,7 +435,9 @@ class TournamentEntryWindow(PopupWindow): else 'retry entry window' ) }, - callback=bui.WeakCall(self._on_tournament_query_response), + callback=bui.WeakCallPartial( + self._on_tournament_query_response + ), ) self._last_query_time = bui.apptime() self._running_query = True @@ -588,6 +592,13 @@ class TournamentEntryWindow(PopupWindow): self._launched = True launched = False + bui.app.analytics.submit_event( + ClassicAnalyticsEvent( + ClassicAnalyticsEvent.EventType.START_TOURNEY_COOP_SESSION, + extra=self._tournament_info.get('game'), + ) + ) + # If they gave us an existing, non-consistent practice activity, # just restart it. if ( @@ -751,7 +762,7 @@ class TournamentEntryWindow(PopupWindow): assert bui.app.plus is not None bui.app.plus.ads.show_ad_2( 'tournament_entry', - on_completion_call=bui.WeakCall(self._on_ad_complete), + on_completion_call=bui.WeakCallPartial(self._on_ad_complete), ) def _on_practice_press(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/tournamentscores.py b/dist/ba_data/python/bauiv1lib/tournamentscores.py index 227e1c6..e334742 100644 --- a/dist/ba_data/python/bauiv1lib/tournamentscores.py +++ b/dist/ba_data/python/bauiv1lib/tournamentscores.py @@ -114,7 +114,7 @@ class TournamentScoresWindow(PopupWindow): 'numScores': 50, 'source': 'scores window', }, - callback=bui.WeakCall(self._on_tournament_query_response), + callback=bui.WeakCallPartial(self._on_tournament_query_response), ) def _on_tournament_query_response( @@ -200,7 +200,7 @@ class TournamentScoresWindow(PopupWindow): bui.textwidget( edit=txt, - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._show_player_info, entry, txt ), ) diff --git a/dist/ba_data/python/bauiv1lib/watch.py b/dist/ba_data/python/bauiv1lib/watch.py index 2b8d923..35375f3 100644 --- a/dist/ba_data/python/bauiv1lib/watch.py +++ b/dist/ba_data/python/bauiv1lib/watch.py @@ -405,7 +405,7 @@ class WatchWindow(bui.MainWindow): bs.new_host_session(mainmenu.MainMenuSession) - bui.fade_screen(False, endcall=bui.Call(bui.pushcall, do_it)) + bui.fade_screen(False, endcall=bui.CallStrict(bui.pushcall, do_it)) bui.containerwidget(edit=self._root_widget, transition='out_left') def _on_my_replay_rename_press(self) -> None: @@ -458,7 +458,7 @@ class WatchWindow(bui.MainWindow): parent=cnt, id=f'{self.main_window_id_prefix}|replayrenamecancel', label=bui.Lstr(resource='cancelText'), - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( lambda c: bui.containerwidget(edit=c, transition='out_scale'), cnt, ), @@ -472,7 +472,7 @@ class WatchWindow(bui.MainWindow): label=bui.Lstr(resource=f'{self._r}.renameText'), size=(180, 60), position=(c_width - 230, 30), - on_activate_call=bui.Call( + on_activate_call=bui.CallStrict( self._rename_my_replay, self._my_replay_selected ), autoselect=True, @@ -558,7 +558,7 @@ class WatchWindow(bui.MainWindow): ) ], ), - bui.Call(self._delete_replay, self._my_replay_selected), + bui.CallStrict(self._delete_replay, self._my_replay_selected), width=450, height=150, ) @@ -616,7 +616,7 @@ class WatchWindow(bui.MainWindow): (1.0, 1, 0.4) if name == '__lastReplay.brp' else (1, 1, 1) ), always_highlight=True, - on_select_call=bui.Call(self._on_my_replay_select, name), + on_select_call=bui.CallStrict(self._on_my_replay_select, name), on_activate_call=self._my_replays_watch_replay_button.activate, text=self._get_replay_display_name(name), h_align='left', diff --git a/dist/ba_data/python/efro/dataclassio/_base.py b/dist/ba_data/python/efro/dataclassio/_base.py index c8a3969..51e7fde 100644 --- a/dist/ba_data/python/efro/dataclassio/_base.py +++ b/dist/ba_data/python/efro/dataclassio/_base.py @@ -93,21 +93,28 @@ class IOMultiType[EnumT: Enum]: """A base class for types that can map to multiple dataclass types. This enables usage of high level base classes (for example a - 'Message' type) in annotations, with dataclassio automatically - serializing & deserializing dataclass subclasses based on their type - ('MessagePing', 'MessageChat', etc.) + ``Message`` type) in annotations, with dataclassio automatically + serializing & deserializing type-specific data for subclasses + (``MessagePing``, ``MessageChat``, etc.) - Standard usage involves creating a class which inherits from this - one which acts as a 'registry', and then creating dataclass classes - inheriting from that registry class. Dataclassio will then do the - right thing when that registry class is used in type annotations. + Standard usage involves a 'registry' class inheriting from this one + and dataclass classes inheriting from that registry class. + Dataclassio will then do the right thing when that registry class is + used in type annotations. - See tests/test_efro/test_dataclassio.py for examples. + For an example multitype class (useful to use as a starting point + for your own) see :class:`efro.dataclassio.templatemultitype`. """ @classmethod def get_type(cls, type_id: EnumT) -> type[Self]: - """Return a specific subclass given a type-id.""" + """Return a specific subclass given a type-id. + + Should be overridden by child classes. Generally, users of the + class should call + :meth:`~efro.dataclassio.IOMultiType.get_type_cached()` instead + of this, as it is more efficient. + """ raise NotImplementedError() @final @@ -220,6 +227,10 @@ class IOAttrs: #: minute boundaries (see :meth:`efro.util.utc_this_minute()`). whole_minutes: bool = False + #: If ``True``, requires ``datetime.datetime`` values to lie exactly on + #: second boundaries (see :meth:`efro.util.utc_this_second()`). + whole_seconds: bool = False + #: If ``True``, values of type ``datetime.datetime`` (in json codec) #: and ``datetime.timedelta`` (in all codecs) will be stored as single #: float timestamp/seconds values instead of the default list of @@ -256,6 +267,12 @@ class IOAttrs: #: editing the value. Does not actually affect value input/output. multiline: bool | None = None + #: If provided for a string, hints whether the value should be + #: edited as distinct options in something like a popup menu instead + #: of as a text field. Can be referenced when creating UI for + #: editing the value. Does not actually affect value input/output. + edit_as_options: bool | None = None + def __init__( self, storagename: str | None = storagename, @@ -264,12 +281,16 @@ class IOAttrs: whole_days: bool = whole_days, whole_hours: bool = whole_hours, whole_minutes: bool = whole_minutes, + whole_seconds: bool = whole_seconds, float_times: bool = float_times, soft_default: Any = MISSING, soft_default_factory: Callable[[], Any] | _MissingType = MISSING, enum_fallback: Enum | None = None, multiline: bool | None = None, + edit_as_options: bool | None = None, ): + # pylint: disable=too-many-branches + # Only store values that differ from class defaults to keep # our instances nice and lean. cls = type(self) @@ -283,6 +304,8 @@ class IOAttrs: self.whole_hours = whole_hours if whole_minutes != cls.whole_minutes: self.whole_minutes = whole_minutes + if whole_seconds != cls.whole_seconds: + self.whole_seconds = whole_seconds if float_times != cls.float_times: self.float_times = float_times if soft_default is not cls.soft_default: @@ -304,6 +327,8 @@ class IOAttrs: self.enum_fallback = enum_fallback if multiline is not cls.multiline: self.multiline = multiline + if edit_as_options is not cls.multiline: + self.edit_as_options = edit_as_options def validate_for_field(self, cls: type, field: dataclasses.Field) -> None: """Ensure the IOAttrs is ok to use with provided field.""" @@ -321,7 +346,7 @@ class IOAttrs: and self.soft_default_factory is self.MISSING ): raise TypeError( - f'Field {field.name} of {cls} has' + f'Field \'{field.name}\' of {cls} has' f' neither a default nor a default_factory' f' and IOAttrs contains neither a soft_default' f' nor a soft_default_factory;' @@ -357,6 +382,11 @@ class IOAttrs: raise ValueError( f'Value {value} at {fieldpath}' f' is not a whole minute.' ) + elif self.whole_seconds: + if any(x != 0 for x in (value.microsecond,)): + raise ValueError( + f'Value {value} at {fieldpath}' f' is not a whole second.' + ) class TypeNotPresentError(TypeError): diff --git a/dist/ba_data/python/efro/dataclassio/_prep.py b/dist/ba_data/python/efro/dataclassio/_prep.py index 4e9cd1d..baf1d87 100644 --- a/dist/ba_data/python/efro/dataclassio/_prep.py +++ b/dist/ba_data/python/efro/dataclassio/_prep.py @@ -266,176 +266,180 @@ class PrepSession: # pylint: disable=too-many-branches # pylint: disable=too-many-statements - if recursion_level > MAX_RECURSION: - raise RuntimeError('Max recursion exceeded.') + if not TYPE_CHECKING: - origin = _get_origin(anntype) + if recursion_level > MAX_RECURSION: + raise RuntimeError('Max recursion exceeded.') - # If we inherit from IOMultiType, we use its type map to - # determine which type we're going to instead of the annotation. - # And we can't really check those types because they are - # lazy-loaded. So I guess we're done here. - if issubclass(origin, IOMultiType): - return + origin = _get_origin(anntype) - if origin is typing.Union or origin is types.UnionType: - self.prep_union( - cls, attrname, anntype, recursion_level=recursion_level + 1 - ) - return + # If we inherit from IOMultiType, we use its type map to + # determine which type we're going to instead of the + # annotation. And we can't really check those types because + # they are lazy-loaded. So I guess we're done here. + if issubclass(origin, IOMultiType): + return - if anntype is typing.Any: - return + if origin is typing.Union or origin is types.UnionType: + self.prep_union( + cls, attrname, anntype, recursion_level=recursion_level + 1 + ) + return - # Everything below this point assumes the annotation type - # resolves to a concrete type. - if not isinstance(origin, type): - raise TypeError( - f'Unsupported type found for \'{attrname}\' on {cls}:' - f' {anntype}' - ) + if anntype is typing.Any: + return - # If a soft_default value/factory was passed, we do some basic - # type checking on the top-level value here. We also run full - # recursive validation on values later during inputting, but - # this should catch at least some errors early on, which can be - # useful since soft_defaults are not static type checked. - if ioattrs is not None: - have_soft_default = False - soft_default: Any = None - if ioattrs.soft_default is not ioattrs.MISSING: - have_soft_default = True - soft_default = ioattrs.soft_default - elif ioattrs.soft_default_factory is not ioattrs.MISSING: - assert callable(ioattrs.soft_default_factory) - have_soft_default = True - soft_default = ioattrs.soft_default_factory() + # Everything below this point assumes the annotation type + # resolves to a concrete type. + if not isinstance(origin, type): + raise TypeError( + f'Unsupported type found for \'{attrname}\' on {cls}:' + f' {anntype}' + ) - # Do a simple type check for the top level to catch basic - # soft_default mismatches early; full check will happen at - # input time. - if have_soft_default: - if not isinstance(soft_default, origin): + # If a soft_default value/factory was passed, we do some + # basic type checking on the top-level value here. We also + # run full recursive validation on values later during + # inputting, but this should catch at least some errors + # early on, which can be useful since soft_defaults are not + # static type checked. + if ioattrs is not None: + have_soft_default = False + soft_default: Any = None + if ioattrs.soft_default is not ioattrs.MISSING: + have_soft_default = True + soft_default = ioattrs.soft_default + elif ioattrs.soft_default_factory is not ioattrs.MISSING: + assert callable(ioattrs.soft_default_factory) + have_soft_default = True + soft_default = ioattrs.soft_default_factory() + + # Do a simple type check for the top level to catch basic + # soft_default mismatches early; full check will happen at + # input time. + if have_soft_default: + if not isinstance(soft_default, origin): + raise TypeError( + f'{cls} attr {attrname} has type {origin}' + f' but soft_default value is type' + f' {type(soft_default)}' + ) + + if origin in SIMPLE_TYPES: + return + + # For sets and lists, check out their single contained type (if + # any). + if origin in (list, set): + childtypes = typing.get_args(anntype) + if len(childtypes) == 0: + # This is equivalent to Any; nothing else needs + # checking. + return + if len(childtypes) > 1: raise TypeError( - f'{cls} attr {attrname} has type {origin}' - f' but soft_default value is type {type(soft_default)}' + f'Unrecognized typing arg count {len(childtypes)}' + f" for {anntype} attr '{attrname}' on {cls}" + ) + self.prep_type( + cls, + attrname, + childtypes[0], + ioattrs=None, + recursion_level=recursion_level + 1, + ) + return + + if origin is dict: + childtypes = typing.get_args(anntype) + assert len(childtypes) in (0, 2) + + # For key types we support Any, str, int, + # and Enums with uniform str/int values. + if not childtypes or childtypes[0] is typing.Any: + # 'Any' needs no further checks (just checked + # per-instance). + pass + elif childtypes[0] in (str, int): + # str and int are all good as keys. + pass + elif issubclass(childtypes[0], Enum): + # Allow our usual str or int enum types as keys. + self.prep_enum(childtypes[0], ioattrs=None) + else: + raise TypeError( + f'Dict key type {childtypes[0]} for \'{attrname}\'' + f' on {cls.__name__} is not supported by dataclassio.' ) - if origin in SIMPLE_TYPES: - return - - # For sets and lists, check out their single contained type (if - # any). - if origin in (list, set): - childtypes = typing.get_args(anntype) - if len(childtypes) == 0: - # This is equivalent to Any; nothing else needs - # checking. + # For value types we support any of our normal types. + if not childtypes or _get_origin(childtypes[1]) is typing.Any: + # 'Any' needs no further checks (just checked + # per-instance). + pass + else: + self.prep_type( + cls, + attrname, + childtypes[1], + ioattrs=None, + recursion_level=recursion_level + 1, + ) return - if len(childtypes) > 1: - raise TypeError( - f'Unrecognized typing arg count {len(childtypes)}' - f" for {anntype} attr '{attrname}' on {cls}" - ) - self.prep_type( - cls, - attrname, - childtypes[0], - ioattrs=None, - recursion_level=recursion_level + 1, + + # For Tuples, simply check individual member types. (and, for + # now, explicitly disallow zero member types or usage of + # ellipsis) + if origin is tuple: + childtypes = typing.get_args(anntype) + if not childtypes: + raise TypeError( + f'Tuple at \'{attrname}\'' + f' has no type args; dataclassio requires type args.' + ) + if childtypes[-1] is ...: + raise TypeError( + f'Found ellipsis as part of type for' + f' \'{attrname}\' on {cls.__name__};' + f' these are not' + f' supported by dataclassio.' + ) + for childtype in childtypes: + self.prep_type( + cls, + attrname, + childtype, + ioattrs=None, + recursion_level=recursion_level + 1, + ) + return + + if issubclass(origin, Enum): + self.prep_enum(origin, ioattrs=ioattrs) + return + + # We allow datetime objects (and google's extended subclass of + # them used in firestore, which is why we don't look for exact + # type here). + if issubclass(origin, datetime.datetime): + return + + # We support datetime.timedelta. + if issubclass(origin, datetime.timedelta): + return + + if dataclasses.is_dataclass(origin): + self.prep_dataclass(origin, recursion_level=recursion_level + 1) + return + + if origin is bytes: + return + + raise TypeError( + f"Attr '{attrname}' on {cls.__name__} contains" + f" type '{anntype}'" + f' which is not supported by dataclassio.' ) - return - - if origin is dict: - childtypes = typing.get_args(anntype) - assert len(childtypes) in (0, 2) - - # For key types we support Any, str, int, - # and Enums with uniform str/int values. - if not childtypes or childtypes[0] is typing.Any: - # 'Any' needs no further checks (just checked - # per-instance). - pass - elif childtypes[0] in (str, int): - # str and int are all good as keys. - pass - elif issubclass(childtypes[0], Enum): - # Allow our usual str or int enum types as keys. - self.prep_enum(childtypes[0], ioattrs=None) - else: - raise TypeError( - f'Dict key type {childtypes[0]} for \'{attrname}\'' - f' on {cls.__name__} is not supported by dataclassio.' - ) - - # For value types we support any of our normal types. - if not childtypes or _get_origin(childtypes[1]) is typing.Any: - # 'Any' needs no further checks (just checked - # per-instance). - pass - else: - self.prep_type( - cls, - attrname, - childtypes[1], - ioattrs=None, - recursion_level=recursion_level + 1, - ) - return - - # For Tuples, simply check individual member types. (and, for - # now, explicitly disallow zero member types or usage of - # ellipsis) - if origin is tuple: - childtypes = typing.get_args(anntype) - if not childtypes: - raise TypeError( - f'Tuple at \'{attrname}\'' - f' has no type args; dataclassio requires type args.' - ) - if childtypes[-1] is ...: - raise TypeError( - f'Found ellipsis as part of type for' - f' \'{attrname}\' on {cls.__name__};' - f' these are not' - f' supported by dataclassio.' - ) - for childtype in childtypes: - self.prep_type( - cls, - attrname, - childtype, - ioattrs=None, - recursion_level=recursion_level + 1, - ) - return - - if issubclass(origin, Enum): - self.prep_enum(origin, ioattrs=ioattrs) - return - - # We allow datetime objects (and google's extended subclass of - # them used in firestore, which is why we don't look for exact - # type here). - if issubclass(origin, datetime.datetime): - return - - # We support datetime.timedelta. - if issubclass(origin, datetime.timedelta): - return - - if dataclasses.is_dataclass(origin): - self.prep_dataclass(origin, recursion_level=recursion_level + 1) - return - - if origin is bytes: - return - - raise TypeError( - f"Attr '{attrname}' on {cls.__name__} contains" - f" type '{anntype}'" - f' which is not supported by dataclassio.' - ) def prep_union( self, cls: type, attrname: str, anntype: Any, recursion_level: int diff --git a/dist/ba_data/python/efro/dataclassio/templatemultitype.py b/dist/ba_data/python/efro/dataclassio/templatemultitype.py index 9e91853..36889c1 100644 --- a/dist/ba_data/python/efro/dataclassio/templatemultitype.py +++ b/dist/ba_data/python/efro/dataclassio/templatemultitype.py @@ -1,23 +1,21 @@ # Released under the MIT License. See LICENSE for details. # -"""Template for an IOMultitype setup. +"""Template for a multi-type class setup. To use this template, simply copy the contents of this module somewhere -and then replace 'TemplateMultiType' with 'YourType'. +and then replace 'TemplateMultiType' with 'MyAwesomeTypeName' or +whatnot. """ from __future__ import annotations -from typing import TYPE_CHECKING, assert_never, override +from typing import assert_never, override from enum import Enum from dataclasses import dataclass from efro.dataclassio import ioprepped, IOMultiType -if TYPE_CHECKING: - pass - class TemplateMultiTypeTypeID(Enum): """Type ID for each of our subclasses.""" diff --git a/dist/ba_data/python/efro/debug.py b/dist/ba_data/python/efro/debug.py index a62ee87..2d87280 100644 --- a/dist/ba_data/python/efro/debug.py +++ b/dist/ba_data/python/efro/debug.py @@ -10,6 +10,7 @@ For this reason, these methods should NEVER be called in production code. Enable them only for debugging situations and be aware that their use may itself cause problems. The same is true for the gc module itself. """ + from __future__ import annotations import os diff --git a/dist/ba_data/python/efro/error.py b/dist/ba_data/python/efro/error.py index 022d824..d294404 100644 --- a/dist/ba_data/python/efro/error.py +++ b/dist/ba_data/python/efro/error.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Common errors and related functionality.""" + from __future__ import annotations from typing import TYPE_CHECKING, override diff --git a/dist/ba_data/python/efro/logging.py b/dist/ba_data/python/efro/logging.py index 3b431cd..5fe1876 100644 --- a/dist/ba_data/python/efro/logging.py +++ b/dist/ba_data/python/efro/logging.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Logging functionality.""" + from __future__ import annotations import sys diff --git a/dist/ba_data/python/efro/message/_receiver.py b/dist/ba_data/python/efro/message/_receiver.py index efb9947..1763b58 100644 --- a/dist/ba_data/python/efro/message/_receiver.py +++ b/dist/ba_data/python/efro/message/_receiver.py @@ -118,14 +118,14 @@ class MessageReceiver: # Return types can be a single type or a union of types. if isinstance(ret, (_GenericAlias, types.UnionType)): targs = get_args(ret) - if not all(isinstance(a, (type, type(None))) for a in targs): + if not all(isinstance(a, type | None) for a in targs): raise TypeError( f'expected only types for "return" annotation;' f' got {targs}.' ) responsetypes = targs else: - if not isinstance(ret, (type, type(None))): + if not isinstance(ret, type | None): raise TypeError( f'expected one or more types for' f' "return" annotation; got a {type(ret)}.' diff --git a/dist/ba_data/python/efro/terminal.py b/dist/ba_data/python/efro/terminal.py index 0ccfadb..4ddbcf1 100644 --- a/dist/ba_data/python/efro/terminal.py +++ b/dist/ba_data/python/efro/terminal.py @@ -1,6 +1,7 @@ # Released under the MIT License. See LICENSE for details. # """Functionality related to terminal IO.""" + from __future__ import annotations import sys @@ -314,7 +315,7 @@ class ClrNever(ClrBase): _envval = os.environ.get('EFRO_TERMCOLORS') -color_enabled: bool = ( +color_enabled: bool = ( # pylint: disable=invalid-name True if _envval == '1' else False if _envval == '0' else _default_color_enabled() diff --git a/dist/ba_data/python/efro/threadpool.py b/dist/ba_data/python/efro/threadpool.py index 5a763b6..685263b 100644 --- a/dist/ba_data/python/efro/threadpool.py +++ b/dist/ba_data/python/efro/threadpool.py @@ -10,6 +10,8 @@ import threading from typing import TYPE_CHECKING, ParamSpec from concurrent.futures import ThreadPoolExecutor +from efro.util import strip_exception_tracebacks + if TYPE_CHECKING: from typing import Any, Callable from concurrent.futures import Future @@ -83,5 +85,8 @@ class ThreadPoolExecutorEx(ThreadPoolExecutor): self.no_wait_count -= 1 try: fut.result() - except Exception: + except Exception as exc: logger.exception('Error in work submitted via submit_no_wait().') + # We're done with this exception, so strip its traceback to + # avoid reference cycles. + strip_exception_tracebacks(exc) diff --git a/dist/ba_data/python/efro/util.py b/dist/ba_data/python/efro/util.py index aa2cd7b..9ded139 100644 --- a/dist/ba_data/python/efro/util.py +++ b/dist/ba_data/python/efro/util.py @@ -162,6 +162,20 @@ def utc_this_minute() -> datetime.datetime: ) +def utc_this_second() -> datetime.datetime: + """Get offset-aware beginning of current second in the utc time zone.""" + now = datetime.datetime.now(datetime.UTC) + return datetime.datetime( + year=now.year, + month=now.month, + day=now.day, + hour=now.hour, + minute=now.minute, + second=now.second, + tzinfo=now.tzinfo, + ) + + def empty_weakref[T](objtype: type[T]) -> weakref.ref[T]: """Return an invalidated weak-reference for the specified type.""" # At runtime, all weakrefs are the same; our type arg is just @@ -348,36 +362,39 @@ def dispatchmethod[ArgT, RetT]( """ from functools import singledispatch, update_wrapper - origwrapper: Any = singledispatch(func) - - # Pull this out so hopefully origwrapper can die, - # otherwise we reference origwrapper in our wrapper. - dispatch = origwrapper.dispatch - - # All we do here is recreate the end of functools.singledispatch - # where it returns a wrapper except instead of the wrapper using the - # first arg to the function ours uses the second (to skip 'self'). - # This was made against Python 3.7; we should probably check up on - # this in later versions in case anything has changed. - # (or hopefully they'll add this functionality to their version) - # NOTE: sounds like we can use functools singledispatchmethod in 3.8 - def wrapper(*args: Any, **kw: Any) -> Any: - if not args or len(args) < 2: - raise TypeError( - f'{funcname} requires at least ' '2 positional arguments' - ) - - return dispatch(args[1].__class__)(*args, **kw) - - funcname = getattr(func, '__name__', 'dispatchmethod method') - wrapper.register = origwrapper.register # type: ignore - wrapper.dispatch = dispatch # type: ignore - wrapper.registry = origwrapper.registry # type: ignore # pylint: disable=protected-access - wrapper._clear_cache = origwrapper._clear_cache # type: ignore - update_wrapper(wrapper, func) - # pylint: enable=protected-access - return cast(DispatchMethodWrapper, wrapper) + # pylint: disable=no-else-return + + if TYPE_CHECKING: + return cast(DispatchMethodWrapper, None) + else: + origwrapper: Any = singledispatch(func) + + # Pull this out so hopefully origwrapper can die, + # otherwise we reference origwrapper in our wrapper. + dispatch = origwrapper.dispatch + + # All we do here is recreate the end of functools.singledispatch + # where it returns a wrapper except instead of the wrapper using the + # first arg to the function ours uses the second (to skip 'self'). + # This was made against Python 3.7; we should probably check up on + # this in later versions in case anything has changed. + # (or hopefully they'll add this functionality to their version) + # NOTE: sounds like we can use functools singledispatchmethod in 3.8 + def wrapper(*args: Any, **kw: Any) -> Any: + if not args or len(args) < 2: + raise TypeError( + f'{funcname} requires at least ' '2 positional arguments' + ) + return dispatch(args[1].__class__)(*args, **kw) + + funcname = getattr(func, '__name__', 'dispatchmethod method') + wrapper.register = origwrapper.register + wrapper.dispatch = dispatch + wrapper.registry = origwrapper.registry + wrapper._clear_cache = origwrapper._clear_cache + update_wrapper(wrapper, func) + return cast(DispatchMethodWrapper, wrapper) def valuedispatch[ValT, RetT]( @@ -579,7 +596,7 @@ def asserttype_o[T](obj: Any, typ: type[T]) -> T | None: failures are not expected. Otherwise use checktype. """ assert isinstance(typ, type), 'only actual types accepted' - assert isinstance(obj, (typ, type(None))) + assert isinstance(obj, typ | None) return obj @@ -602,7 +619,7 @@ def checktype_o[T](obj: Any, typ: type[T]) -> T | None: on failure. Use asserttype for more efficient (but less safe) equivalent. """ assert isinstance(typ, type), 'only actual types accepted' - if not isinstance(obj, (typ, type(None))): + if not isinstance(obj, typ | None): raise TypeError(f'Expected a {typ} or None; got a {type(obj)}.') return obj @@ -628,7 +645,7 @@ def warntype_o[T](obj: Any, typ: type[T]) -> T | None: not what is expected. """ assert isinstance(typ, type), 'only actual types accepted' - if not isinstance(obj, (typ, type(None))): + if not isinstance(obj, typ | None): import logging logging.warning( @@ -823,7 +840,10 @@ def set_canonical_module_names(module_globals: dict[str, Any]) -> None: def timedelta_str( - timeval: datetime.timedelta | float, *, maxparts: int = 2, decimals: int = 0 + timeval: datetime.timedelta | float | int, + *, + maxparts: int = 2, + decimals: int = 0, ) -> str: """Return a simple human readable time string for a length of time. @@ -840,7 +860,7 @@ def timedelta_str( """ # pylint: disable=too-many-locals - if isinstance(timeval, float): + if isinstance(timeval, float | int): timevalfin = datetime.timedelta(seconds=timeval) else: timevalfin = timeval @@ -1008,7 +1028,8 @@ def weighted_choice[T](*args: tuple[T, float]) -> T: items: tuple[T] weights: tuple[float] items, weights = zip(*args) - return random.choices(items, weights=weights)[0] + val: T = random.choices(items, weights=weights)[0] + return val def prune_empty_dirs(prunedir: str) -> None: @@ -1068,3 +1089,17 @@ def strip_exception_tracebacks(exc: BaseException) -> None: cause = getattr(e, '__cause__', None) if cause is not None: stack.append(cause) + + +def secure_id() -> str: + """Generate a 20 char cryptographically secure string. + + Basically what firestore does for its random document ids. + If its good enough for firestore its good enough for us. + """ + import secrets + import string + + alphabet = string.ascii_letters + string.digits # 62 chars + + return ''.join(secrets.choice(alphabet) for _ in range(20)) diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index bb17ac3..aa48416 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ