diff --git a/dist/ba_data/python/babase/__init__.py b/dist/ba_data/python/babase/__init__.py index 61c08a8..144748f 100644 --- a/dist/ba_data/python/babase/__init__.py +++ b/dist/ba_data/python/babase/__init__.py @@ -74,6 +74,7 @@ from _babase import ( request_main_ui, is_os_playing_music, is_xcode_build, + LangStr, lock_all_input, mac_music_app_get_playlists, mac_music_app_get_volume, @@ -112,6 +113,7 @@ from _babase import ( set_account_sign_in_state, set_ui_scale, show_progress_bar, + split_text_into_lines, shutdown_suppress_begin, shutdown_suppress_end, shutdown_suppress_count, @@ -189,6 +191,7 @@ from babase._general import ( verify_object_death, ) from babase._language import ( + LangStrDir, LanguageSubsystem, Lstr, get_legacy_langdata, @@ -215,6 +218,7 @@ from babase._math import normalized_color, is_point_in_box, vec3validate from babase._meta import MetadataSubsystem from babase._assetsubsystem import ( AssetSubsystem, + make_progress_reporter, ResolveResult, ResolveProgress, ResolvePhase, @@ -346,10 +350,13 @@ __all__ = [ 'loaded_asset_package_apverids', 'LocaleSubsystem', 'lifecyclelog', + 'LangStr', + 'LangStrDir', 'lock_all_input', 'LoginAdapter', 'LoginInfo', 'Lstr', + 'make_progress_reporter', 'mac_music_app_get_playlists', 'mac_music_app_get_volume', 'mac_music_app_init', @@ -406,6 +413,7 @@ __all__ = [ 'set_account_sign_in_state', 'set_ui_scale', 'show_progress_bar', + 'split_text_into_lines', 'shutdown_suppress_begin', 'shutdown_suppress_end', 'shutdown_suppress_count', diff --git a/dist/ba_data/python/babase/_assetsubsystem.py b/dist/ba_data/python/babase/_assetsubsystem.py index 0a18601..991f16f 100644 --- a/dist/ba_data/python/babase/_assetsubsystem.py +++ b/dist/ba_data/python/babase/_assetsubsystem.py @@ -218,6 +218,17 @@ class AssetClientTooOldError(AssetResolveError): """ +class AssetContentError(AssetResolveError): + """Tier-1 resolve failed: the package's own source content is bad. + + The package failed to build due to a problem in its source assets + (e.g. a malformed sound or texture file) — something the package + author can fix. ``server_message`` names the offending source + file(s), so surface it verbatim. Raised when the server returns + :attr:`~bacommon.cloud.AssetPackageResolveError.CONTENT`. + """ + + class AssetResolveAbortedError(AssetResolveError): """An asset-subsystem operation was abandoned because we're shutting down. @@ -229,6 +240,19 @@ class AssetResolveAbortedError(AssetResolveError): """ +#: Which :class:`AssetResolveError` subclass a Tier-1 resolve raises for +#: each structured server code (codes without an entry get the base +#: class). +_RESOLVE_ERROR_TYPES: dict[ + AssetPackageResolveError, type[AssetResolveError] +] = { + AssetPackageResolveError.AUTH_REQUIRED: AssetAuthRequiredError, + AssetPackageResolveError.ACCESS_DENIED: AssetAccessDeniedError, + AssetPackageResolveError.CLIENT_TOO_OLD: AssetClientTooOldError, + AssetPackageResolveError.CONTENT: AssetContentError, +} + + @dataclass class _GcSweepStats: """Outcome of one GC sweep (internal).""" @@ -367,7 +391,7 @@ def make_progress_reporter( Pass the returned callable as ``on_progress`` to :meth:`AssetSubsystem.resolve`. It calls ``on_update(message, progress)`` immediately on a phase or package change and then at most once per - :data:`_PROGRESS_UPDATE_INTERVAL` seconds (so a slow download keeps + a short throttle interval (so a slow download keeps the user informed without spamming). ``progress`` is a ``0.0``–``1.0`` fraction for a progress bar — ``0.0`` during phases with no known count (the bar is held at zero rather than hidden, so the display doesn't resize @@ -493,6 +517,24 @@ class _CachedPackage: last_used: Annotated[float, IOAttrs('lu')] +#: Layout epoch of the flavor-manifest blobs the cache manifest +#: references. Bumped when the server-side manifest shape changes +#: incompatibly, so a client upgrading across the change discards its +#: cached (old-shape) flavor manifests wholesale instead of serving +#: them to lookups that expect the new shape. Discard is cheap: leaf +#: data blobs are shape-invariant and stay reusable by hash, so a +#: re-resolve only re-downloads the small flavor-manifest blobs (the +#: orphaned old ones get swept by GC). History: 1 = original shape +#: (container-extension keys + single-char parts); 2 = pure +#: logical-path keys + ``.`` parts (asset-packages +#: decision #35, 2026-07-19); 3 = same shape as 2 — bumped purely to +#: flush caches poisoned during the #35 rollout window, when servers +#: could still hand a new client old-shape manifests that were then +#: committed under epoch 2 (see the ingestion-time shape validation +#: in ``_tier1_download``, which prevents that class going forward). +_CACHE_MANIFEST_LAYOUT_VERSION = 3 + + @ioprepped @dataclass class _CacheManifest: @@ -515,6 +557,13 @@ class _CacheManifest: field(default_factory=dict) ) + #: The ``_CACHE_MANIFEST_LAYOUT_VERSION`` this manifest was written + #: at. Defaults to 1 (not the current version!) so manifests + #: predating the field read as the original epoch; construction + #: sites must pass the current version explicitly. A mismatch on + #: load discards the manifest (see ``_load_manifest``). + layout_version: Annotated[int, IOAttrs('v')] = 1 + class AssetSubsystem(AppSubsystem): """Subsystem for acquiring + tracking downloadable asset packages. @@ -959,7 +1008,7 @@ class AssetSubsystem(AppSubsystem): :class:`~bacommon.loctext.StringSelector`) read from the package's resolved ``language/`` blob -- the Python side of what the native ``ReloadLanguage`` consumes, for the language-agnostic - (``Lstr``) doc-ui decode path. ``locale`` must be the one the package + (``LangStr``) doc-ui decode path. ``locale`` must be the one the package was :meth:`resolve`\\ d for (the coord is ``language/``, matching the ``_desired_coords`` bucket map). @@ -983,10 +1032,10 @@ class AssetSubsystem(AppSubsystem): if fm_hash is None or self._locate_blob(fm_hash) is None: return {} - # The blob lives at logical path 'language.json' part 'j' (the same - # one native ReloadLanguage looks up). - parts = self._read_entries(fm_hash).get('language.json') - blob_hash = parts.get('j') if parts else None + # The blob lives at logical path 'language' part 'j.json' (the + # same one native ReloadLanguage looks up). + parts = self._read_entries(fm_hash).get('language') + blob_hash = parts.get('j.json') if parts else None if blob_hash is None: return {} path = self._locate_blob(blob_hash) @@ -1009,7 +1058,10 @@ class AssetSubsystem(AppSubsystem): """ from babase._asset_packages import loaded_asset_package_apverids - _babase.reload_language(loaded_asset_package_apverids()) + # The resolved locale's wire value drives native CLDR plural + # selection for language-string evaluation. + plural_locale = _babase.app.locale.current_locale.resolved.locale.value + _babase.reload_language(loaded_asset_package_apverids(), plural_locale) # --------------------------------------------------------------------- # Resolve internals. @@ -1364,6 +1416,33 @@ class AssetSubsystem(AppSubsystem): for p, info in parsed['e'].items() } + @staticmethod + def _validate_manifest_shape( + apverid: str, coord: str, parsed: dict + ) -> None: + """Refuse a downloaded flavor-manifest from an older layout epoch. + + Ingestion-time shape validation: nothing else guards + server-too-old — a mid-rollout server (or its serve-stale cache) + handing us an older-epoch manifest would otherwise silently miss + every lookup AND poison our writable cache until manually + cleared (exactly what happened in the decision-#35 reshape + rollout, 2026-07-19). Raising here (before any write) means a + mismatched manifest is never committed. Discriminator: every + part key is ``.`` in the current epoch, so any + dotless part key marks a pre-#35 manifest; this check must move + in lockstep with any future shape change. + """ + for info in parsed['e'].values(): + for part in info: + if '.' not in part: + raise AssetResolveError( + f'{apverid}: server manifest for {coord!r} is from' + f' an older layout epoch (part {part!r}); refusing' + f' to ingest. The server may be mid-update; retry' + f' later.' + ) + async def _tier1_download( self, apverid: str, language: Locale ) -> dict[str, str]: @@ -1401,13 +1480,10 @@ class AssetSubsystem(AppSubsystem): # Raise a specific subclass for the cases callers branch on # (e.g. construct-mode prompting for sign-in). Carry the # server's raw message too so callers can show its wording. - if code is AssetPackageResolveError.AUTH_REQUIRED: - raise AssetAuthRequiredError(msg, code, response.error) - if code is AssetPackageResolveError.ACCESS_DENIED: - raise AssetAccessDeniedError(msg, code, response.error) - if code is AssetPackageResolveError.CLIENT_TOO_OLD: - raise AssetClientTooOldError(msg, code, response.error) - raise AssetResolveError(msg, code, response.error) + errcls = AssetResolveError + if code is not None: + errcls = _RESOLVE_ERROR_TYPES.get(code, AssetResolveError) + raise errcls(msg, code, response.error) if not response.buckets: raise AssetResolveError(f'{apverid}: resolve returned no buckets.') @@ -1423,6 +1499,7 @@ class AssetSubsystem(AppSubsystem): ): fm_writes[flavor_manifest.hash] = flavor_manifest.data parsed = json.loads(flavor_manifest.data) + self._validate_manifest_shape(apverid, coord, parsed) # The manifest carries only canonical content identity (hash + # size); a blob's transfer encoding is negotiated per /casblob # download (see _acquire_data_blob), not recorded here. @@ -1770,15 +1847,33 @@ class AssetSubsystem(AppSubsystem): path = self._manifest_path try: with open(path, encoding='utf-8') as infile: - return dataclass_from_json(_CacheManifest, infile.read()) + manifest = dataclass_from_json(_CacheManifest, infile.read()) except FileNotFoundError: - return _CacheManifest() + return self._fresh_manifest() except Exception as exc: logger.exception( 'Error loading asset cache manifest %s; starting fresh.', path ) strip_exception_tracebacks(exc) - return _CacheManifest() + return self._fresh_manifest() + if manifest.layout_version != _CACHE_MANIFEST_LAYOUT_VERSION: + # The cached flavor manifests were written at a different + # shape epoch than this build expects; drop them wholesale + # (packages simply re-resolve; shape-invariant data blobs + # stay reusable by hash and orphans get swept by GC). + logger.info( + 'Discarding asset cache manifest at layout version %d' + ' (current is %d).', + manifest.layout_version, + _CACHE_MANIFEST_LAYOUT_VERSION, + ) + return self._fresh_manifest() + return manifest + + @staticmethod + def _fresh_manifest() -> _CacheManifest: + """An empty cache manifest at the current layout version.""" + return _CacheManifest(layout_version=_CACHE_MANIFEST_LAYOUT_VERSION) def _commit_manifest( self, manifest_pkgs: dict[str, dict[str, str]], now: float @@ -1919,7 +2014,9 @@ class AssetSubsystem(AppSubsystem): } self._persist_manifest( _CacheManifest( - packages=new_packages, flavor_manifest_last_used=new_fmlu + packages=new_packages, + flavor_manifest_last_used=new_fmlu, + layout_version=_CACHE_MANIFEST_LAYOUT_VERSION, ) ) return live, time.monotonic() - start @@ -2043,7 +2140,7 @@ class AssetSubsystem(AppSubsystem): value = int(infile.read().strip(), 16) if 0 <= value < _CAS_SHARD_COUNT: return value - except (OSError, ValueError): + except OSError, ValueError: pass return 0 diff --git a/dist/ba_data/python/babase/_constructmode.py b/dist/ba_data/python/babase/_constructmode.py index 2a75fe7..b6d9491 100644 --- a/dist/ba_data/python/babase/_constructmode.py +++ b/dist/ba_data/python/babase/_constructmode.py @@ -18,6 +18,7 @@ from babase._assetsubsystem import ( AssetAuthRequiredError, AssetAccessDeniedError, AssetClientTooOldError, + AssetContentError, AssetResolveAbortedError, make_progress_reporter, ) @@ -326,6 +327,19 @@ class ConstructAppMode(AppMode): ' Please update to continue.' ) strip_exception_tracebacks(exc) + except AssetContentError as exc: + # A source asset in the package failed to build — something + # its author can fix. Surface the server's message verbatim; + # it names the offending source file(s). This audience is + # nearly always the author (dev/test versions only resolve + # for the owner/dev-team), so speak to them directly. + logger.warning('Construct-mode: asset content error: %s', exc) + detail = exc.server_message or 'An asset failed to build.' + self._fail( + f'{detail} Fix the file in the source workspace and' + f' try again.' + ) + strip_exception_tracebacks(exc) except AssetResolveAbortedError as exc: # The app started shutting down mid-resolve (e.g. the user # quit while a download/cloud-build was still in flight). diff --git a/dist/ba_data/python/babase/_env.py b/dist/ba_data/python/babase/_env.py index adfa506..9b25ff8 100644 --- a/dist/ba_data/python/babase/_env.py +++ b/dist/ba_data/python/babase/_env.py @@ -382,6 +382,7 @@ def _do_pycache_upkeep() -> None: if complained: cachelog.debug('(repeat) Error updating pycache dir: %s', msg) return + complained = True cachelog.warning('Error updating pycache dir: %s', msg) # Build a dict of dst pyc paths mapped to src py paths and @@ -514,7 +515,7 @@ def _do_pycache_upkeep() -> None: dstpath ) or srcmtime > os.path.getmtime(dstpath) if still_out_of_date: - complain(f'Error precompiling {fullpath}: {exc}') + complain(f'Error precompiling {srcpath}: {exc}') assert complained if should_abort(): diff --git a/dist/ba_data/python/babase/_general.py b/dist/ba_data/python/babase/_general.py index 4ce362e..78e0703 100644 --- a/dist/ba_data/python/babase/_general.py +++ b/dist/ba_data/python/babase/_general.py @@ -168,7 +168,7 @@ else: """ # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] + __slots__ = ['_call', '_args', '_keywds', '__wrapped__'] _did_invalid_call_warning = False @@ -179,6 +179,11 @@ else: # non-partial versions if you want to access those. if hasattr(call, '__func__'): self._call = WeakMethod(call) + # Stdlib __wrapped__ convention, for diagnostics (see + # CallStrict). The plain function, not the bound method + # -- strong-refing the method would keep its target + # alive and defeat the weak ref. + self.__wrapped__ = call.__func__ else: if not self._did_invalid_call_warning: logging.warning( @@ -190,6 +195,7 @@ else: ) type(self)._did_invalid_call_warning = True self._call = call + self.__wrapped__ = call self._args = args self._keywds = keywds @@ -235,7 +241,7 @@ else: """ # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] + __slots__ = ['_call', '_args', '_keywds', '__wrapped__'] def __init__(self, call: Any, /, *args: Any, **keywds: Any): # Note: keeping _call, _args, _keywds private in this case @@ -246,6 +252,10 @@ else: self._args = args self._keywds = keywds + # Stdlib __wrapped__ convention, for diagnostics (see + # CallStrict). + self.__wrapped__ = call + 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: @@ -287,7 +297,7 @@ else: """ # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] + __slots__ = ['_call', '_args', '_keywds', '__wrapped__'] _did_invalid_call_warning = False @@ -298,6 +308,11 @@ else: # non-partial versions if you want to access those. if hasattr(call, '__func__'): self._call = WeakMethod(call) + # Stdlib __wrapped__ convention, for diagnostics (see + # CallStrict). The plain function, not the bound method + # -- strong-refing the method would keep its target + # alive and defeat the weak ref. + self.__wrapped__ = call.__func__ else: if not self._did_invalid_call_warning: logging.warning( @@ -308,7 +323,8 @@ else: stack_info=True, ) type(self)._did_invalid_call_warning = True - self._call = call + self._call = call + self.__wrapped__ = call self._args = args self._keywds = keywds @@ -352,7 +368,7 @@ else: """ # Optimize performance a bit; we shouldn't need to be super dynamic. - __slots__ = ['_call', '_args', '_keywds'] + __slots__ = ['_call', '_args', '_keywds', '__wrapped__'] def __init__(self, call: Any, /, *args: Any, **keywds: Any): # Note: keeping _call, _args, _keywds private in this case @@ -363,6 +379,10 @@ else: self._args = args self._keywds = keywds + # Stdlib __wrapped__ convention, for diagnostics (see + # CallStrict). + self.__wrapped__ = call + 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: @@ -392,7 +412,7 @@ class CallStrict[**P, T]: recommended if you do not need extra args at call time. """ - __slots__ = ('call', 'args', 'kwargs') + __slots__ = ('call', 'args', 'kwargs', '__wrapped__') def __init__( self, call: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs @@ -404,6 +424,12 @@ class CallStrict[**P, T]: self.args = args self.kwargs = kwargs + # Expose the wrapped callable via the stdlib convention + # (functools.wraps / inspect.unwrap) so diagnostics such as + # efro.threadpool's slow-task warnings can name the real + # target instead of this wrapper class. + self.__wrapped__ = call + def __call__(self) -> T: return self.call(*self.args, **self.kwargs) @@ -422,7 +448,7 @@ class WeakCallStrict[**P, T]: recommended if you do not need extra args at call time. """ - __slots__ = ('call', 'args', 'kwargs') + __slots__ = ('call', 'args', 'kwargs', '__wrapped__') _did_invalid_call_warning = False @@ -434,6 +460,11 @@ class WeakCallStrict[**P, T]: # whatnot that would break this. if hasattr(call, '__func__'): self.call: Any = WeakMethod(call) # type: ignore + # Stdlib __wrapped__ convention, for diagnostics (see + # CallStrict). The plain function, not the bound method -- + # strong-refing the method would keep its target alive and + # defeat the weak ref. + self.__wrapped__: Callable[..., Any] = getattr(call, '__func__') else: if not self._did_invalid_call_warning: logging.warning( @@ -445,6 +476,7 @@ class WeakCallStrict[**P, T]: ) type(self)._did_invalid_call_warning = True self.call = call + self.__wrapped__ = call self.args = args self.kwargs = kwargs diff --git a/dist/ba_data/python/babase/_hooks.py b/dist/ba_data/python/babase/_hooks.py index 345558f..353b012 100644 --- a/dist/ba_data/python/babase/_hooks.py +++ b/dist/ba_data/python/babase/_hooks.py @@ -94,23 +94,6 @@ def open_url_with_webbrowser_module(url: str) -> None: _babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) -def rejecting_invite_already_in_party_message() -> None: - from babase._language import Lstr - - _babase.screenmessage( - Lstr(resource='internal.rejectingInviteAlreadyInPartyText'), - color=(1, 0.5, 0), - ) - - -def connection_failed_message() -> None: - from babase._language import Lstr - - _babase.screenmessage( - Lstr(resource='internal.connectionFailedText'), color=(1, 0.5, 0) - ) - - def temporarily_unavailable_message() -> None: from babase._language import Lstr @@ -197,8 +180,25 @@ def show_post_purchase_message() -> None: def language_test_toggle() -> None: - _babase.app.lang.setlanguage( - 'Gibberish' if _babase.app.lang.language == 'English' else 'English' + """Debug toggle (F9): flip between English and Gibberish. + + Goes through the modern elective locale switch + (:meth:`~babase.LocaleSubsystem.set_locale`), which resolves the + target locale's asset flavors first -- downloading the + ``language/`` blobs if needed, with a progress dialog -- + and commits only on success. + """ + # Deferred: keep bacommon out of babase's module-load graph. + from bacommon.locale import Locale + + # Toggle off where we're *heading*, not where we are: while a + # switch resolves, current_locale still reads the old value, so a + # rapid second press would otherwise re-request the same target + # instead of flipping back (each press must count -- final state + # matches press parity). + locale = _babase.app.locale.target_locale + _babase.app.locale.set_locale( + Locale.GIBBERISH if locale is Locale.ENGLISH else Locale.ENGLISH ) diff --git a/dist/ba_data/python/babase/_language.py b/dist/ba_data/python/babase/_language.py index 11d411a..ddd97c1 100644 --- a/dist/ba_data/python/babase/_language.py +++ b/dist/ba_data/python/babase/_language.py @@ -14,21 +14,102 @@ if TYPE_CHECKING: from typing import Any, Sequence import babase + import bacommon.langstr #: Process-lifetime cache for :func:`get_legacy_langdata` (the constant -#: ``legacylangdata.json`` blob is flavor-invariant, so it is stable for +#: ``legacylangdata`` blob is flavor-invariant, so it is stable for #: the life of the process once read). _g_legacy_langdata: dict[str, Any] | None = None +def _native_from_spec(spec: bacommon.langstr.LangStrSpec) -> babase.LangStr: + """Parse an authoring-spec into the native verified-local form. + + Private on purpose (D28): there is no *public* spec -> verified + conversion — verification comes from context. The callers here are + the wrapper runtime below, whose asset-package pins are + construct-mode-resolved before any wrapper is usable. + """ + from efro.dataclassio import dataclass_to_json + + return _babase.LangStr(dataclass_to_json(spec)) + + +class _NativeLstrMaker: + """Callable leaf: builds a native LangStr from keyword subs.""" + + __slots__ = ('_apverid', '_name') + + def __init__(self, apverid: str, name: str) -> None: + self._apverid = apverid + self._name = name + + def __call__(self, **subs: str | int | babase.LangStr) -> babase.LangStr: + from bacommon.langstr import LangStrSpecResource + + return _native_from_spec( + LangStrSpecResource( + self._apverid, + self._name, + { + key: (val.spec if isinstance(val, _babase.LangStr) else val) + for key, val in subs.items() + }, + ) + ) + + +class LangStrDir: + """Runtime accessor tree for client-destined asset-package wrappers. + + The verified-local counterpart of + :class:`bacommon.langstr.LangStrDir`: generated client wrapper + modules instantiate this over the same tree data, and string leaves + yield native :class:`babase.LangStr` values (per the D28 semantic + split — the construct-mode resolve that gates wrapper use + guarantees these strings are locally displayable). + """ + + __slots__ = ('_apverid', '_tree', '_prefix') + + def __init__( + self, + apverid: str, + tree: bacommon.langstr.WrapperTree, + prefix: str = '', + ) -> None: + self._apverid = apverid + self._tree = tree + self._prefix = prefix + + def __getattr__( + self, name: str + ) -> babase.LangStr | _NativeLstrMaker | LangStrDir: + try: + child = self._tree[name] + except KeyError: + raise AttributeError(name) from None + full = f'{self._prefix}/{name}' if self._prefix else name + if isinstance(child, dict): + return LangStrDir(self._apverid, child, full) + # A leaf: its param-keyword tuple. Empty -> a no-arg string, + # read as a property yielding the native LangStr directly; + # otherwise a maker. + if not child: + from bacommon.langstr import LangStrSpecResource + + return _native_from_spec(LangStrSpecResource(self._apverid, full)) + return _NativeLstrMaker(self._apverid, full) + + def get_legacy_langdata() -> dict[str, Any]: """Return the parsed legacy language-data blob (cached process-wide). This is the legacy ``langdata.json`` payload (translated language names + translation contributors), now sourced from the builtin - asset-package's flavor-invariant ``constant`` bucket - (``legacylangdata.json``) rather than a bundled data file. + asset-package's flavor-invariant ``constant`` bucket (logical path + ``legacylangdata``) rather than a bundled data file. Returns ``{}`` when the blob is unavailable (headless / no bundled asset-package manifest / not yet resolved) or on any read error, so @@ -48,7 +129,7 @@ def get_legacy_langdata() -> dict[str, Any]: result: dict[str, Any] = {} for apverid in loaded_asset_package_apverids(): path = _babase.get_asset_package_constant_blob_path( - apverid, 'legacylangdata.json' + apverid, 'legacylangdata' ) if path is None: continue @@ -200,7 +281,8 @@ class LanguageSubsystem(AppSubsystem): # migration Step A); switching to other locales lands in Step B. from babase._asset_packages import loaded_asset_package_apverids - _babase.reload_language(loaded_asset_package_apverids()) + plural_locale = _babase.app.locale.current_locale.resolved.locale.value + _babase.reload_language(loaded_asset_package_apverids(), plural_locale) if switched and print_change: _babase.screenmessage( diff --git a/dist/ba_data/python/babase/_locale.py b/dist/ba_data/python/babase/_locale.py index 56e495f..ec2f22c 100644 --- a/dist/ba_data/python/babase/_locale.py +++ b/dist/ba_data/python/babase/_locale.py @@ -28,6 +28,13 @@ class LocaleSubsystem(AppSubsystem): def __init__(self) -> None: super().__init__() self._current_locale: Locale | None = None + self._switch_in_progress = False + # Locale an in-flight elective switch is resolving toward. + self._inflight_locale: Locale | None = None + # Latest-wins request queued behind the in-flight switch: + # (locale, store_to_config). Started when the in-flight one + # settles. + self._pending_switch: tuple[Locale, bool] | None = None # Calc our default locale based on the locale-tag provided by # the native layer. @@ -118,6 +125,23 @@ class LocaleSubsystem(AppSubsystem): raise RuntimeError('Locale is not set.') return self._current_locale + @property + def target_locale(self) -> Locale: + """The locale the app is at or is switching toward. + + Equal to :attr:`current_locale` when no elective switch is in + flight; otherwise the most recently requested locale (an + in-flight :meth:`set_locale` target, or the latest request + queued behind it). Useful for toggles and selection UIs, which + should act relative to where the app is *heading*, not where a + still-resolving switch started from. + """ + if self._pending_switch is not None: + return self._pending_switch[0] + if self._inflight_locale is not None: + return self._inflight_locale + return self.current_locale + def set_locale( self, locale: Locale, *, store_to_config: bool = True ) -> None: @@ -143,6 +167,20 @@ class LocaleSubsystem(AppSubsystem): locale.name, ) return + # Only one elective switch runs at a time. A request arriving + # while a resolve is in flight (rapid F9s, double-taps in a + # menu) is queued latest-wins rather than racing the first or + # being dropped -- so every press counts and the app settles on + # the most recent request. + if self._switch_in_progress: + applog.info( + 'Language switch already in progress; queueing switch to %s.', + locale.name, + ) + self._pending_switch = (locale, store_to_config) + return + self._switch_in_progress = True + self._inflight_locale = locale _babase.app.create_async_task( self._do_set_locale(locale, store_to_config) ) @@ -151,6 +189,21 @@ class LocaleSubsystem(AppSubsystem): self, locale: Locale, store_to_config: bool ) -> None: """Resolve + commit a language switch (see :meth:`set_locale`).""" + try: + await self._do_set_locale_guarded(locale, store_to_config) + finally: + self._switch_in_progress = False + self._inflight_locale = None + # Kick off the latest queued request, if any (skipping the + # no-op case where we already landed on it). + pending = self._pending_switch + self._pending_switch = None + if pending is not None and pending[0] is not self._current_locale: + self.set_locale(pending[0], store_to_config=pending[1]) + + async def _do_set_locale_guarded( + self, locale: Locale, store_to_config: bool + ) -> None: import asyncio from babase._simpledialog import SimpleDialog @@ -225,6 +278,15 @@ class LocaleSubsystem(AppSubsystem): if dialog is not None: dialog.dismiss() self._current_locale = locale + + # The resolve's bucket-commit rebuilt the native tables, but at + # that moment current_locale still held the OLD locale, so they + # carry stale plural rules. Rebuild once more now that the + # switch is committed (cheap; buckets unchanged). + from babase._assetsubsystem import AssetSubsystem + + AssetSubsystem._reload_language() # pylint: disable=protected-access + cfg = _babase.app.config if store_to_config: cfg['Lang'] = locale.long_value diff --git a/dist/ba_data/python/babase/_stringedit.py b/dist/ba_data/python/babase/_stringedit.py index 4a37fd2..469e752 100644 --- a/dist/ba_data/python/babase/_stringedit.py +++ b/dist/ba_data/python/babase/_stringedit.py @@ -51,6 +51,7 @@ class StringEditAdapter: initial_text: str, max_length: int | None, screen_space_center: tuple[float, float] | None, + is_password: bool = False, ) -> None: if not _babase.in_logic_thread(): raise RuntimeError('This must be called from the logic thread.') @@ -63,6 +64,8 @@ class StringEditAdapter: self.initial_text = initial_text self.max_length = max_length self.screen_space_center = screen_space_center + # Whether the platform editor should mask input (password entry). + self.is_password = is_password # Attempt to register ourself as the active edit. subsys = _babase.app.stringedit diff --git a/dist/ba_data/python/babase/_text.py b/dist/ba_data/python/babase/_text.py index a60227b..1d885ee 100644 --- a/dist/ba_data/python/babase/_text.py +++ b/dist/ba_data/python/babase/_text.py @@ -2,8 +2,12 @@ # """Text related functionality.""" +import time +import logging from typing import TYPE_CHECKING +import _babase + if TYPE_CHECKING: import babase @@ -85,3 +89,83 @@ def timestring( ) ) return Lstr(value=' '.join(bits), subs=subs) + + +def run_line_break_selftest(iterations: int = 500) -> None: + """Exercise OS line-break analysis; log behavior and timing. + + Feeds sample strings in various scripts through the platform's + line-break-opportunity analysis (UAX #14 via the OS text stack where + implemented), sanity-checks the returned offsets, logs each result + with break opportunities rendered as ``|``, and reports average + per-call time. Logs at warning level so results show up under + default log levels on all platforms. Logic thread only. + """ + samples: list[tuple[str, str]] = [ + ('english', 'Hello there world, how are you today?'), + ('english-hyphen', 'A well-known state-of-the-art solution.'), + ('newlines', 'First line.\nSecond line here.'), + ('japanese', '日本語のテキストは、ほとんどの場所で改行できます。'), + ( + 'japanese-kinsoku', + 'これは「禁則処理」のテストです。ラーメンとカレー。', + ), + ('chinese', '这是一个中文句子,可以在大多数字符之间换行。'), + ('korean', '한국어 텍스트는 공백에서 줄바꿈됩니다.'), + ('thai', 'ภาษาไทยไม่มีช่องว่างระหว่างคำแต่ต้องตัดคำให้ถูกต้อง'), + ('mixed-scripts', 'Player Bob说了hello แล้วก็ไป home.'), + ('emoji', 'Nice 🎉🎊 party 🥳 time!'), + ('empty', ''), + ('single-word', 'Hello'), + ] + logger = logging.getLogger('ba.gfx') + logger.warning('line-break-selftest: starting.') + problems = 0 + for name, text in samples: + offsets = _babase.get_text_line_break_offsets(text) + data = text.encode() + + # Sanity: offsets strictly increasing, in range, and always on + # utf-8 sequence boundaries. + valid = all( + 0 < off < len(data) and (data[off] & 0xC0) != 0x80 + for off in offsets + ) and offsets == sorted(set(offsets)) + if not valid: + problems += 1 + + # Render break opportunities as '|' between segments. + splits = [0, *offsets, len(data)] + segments = [ + data[splits[i] : splits[i + 1]].decode() + for i in range(len(splits) - 1) + ] + logger.warning( + 'line-break-selftest: %s%s: %s', + name, + '' if valid else ' (INVALID OFFSETS)', + '|'.join(segments).replace('\n', '\\n'), + ) + + # Timing: a short string and a longer paragraph. + para = ( + 'The quick brown fox jumps over the lazy dog while ' + '日本語のテキストも含まれていますし、' + 'ภาษาไทยก็มีอยู่ในย่อหน้านี้ด้วย and then some more ' + 'English to round things out nicely with a few extra words.' + ) + for name, text in [('short', samples[0][1]), ('paragraph', para)]: + start = time.monotonic() + for _ in range(iterations): + _babase.get_text_line_break_offsets(text) + duration = time.monotonic() - start + logger.warning( + 'line-break-selftest: timing %s (%d chars): %.1f us per call.', + name, + len(text), + duration / iterations * 1_000_000, + ) + logger.warning( + 'line-break-selftest: complete; %d problem(s).', + problems, + ) diff --git a/dist/ba_data/python/baclassic/_achievement.py b/dist/ba_data/python/baclassic/_achievement.py index 17d7951..fe25a9b 100644 --- a/dist/ba_data/python/baclassic/_achievement.py +++ b/dist/ba_data/python/baclassic/_achievement.py @@ -1030,9 +1030,7 @@ class Achievement: Image( stdassets.textures.achievement_outline, host_only=True, - mesh_transparent=bascenev1.getmesh( - _tex('achievement_outline') - ), + mesh_transparent=stdassets.meshes.achievement_outline, color=(2, 1.4, 0.4, 1), vr_depth=8, position=(x - 25, y + 5), @@ -1360,7 +1358,7 @@ class Achievement: obj = Image( stdassets.textures.achievement_outline, - mesh_transparent=bascenev1.getmesh(_tex('achievement_outline')), + mesh_transparent=stdassets.meshes.achievement_outline, position=(-180, 60 + y_offs), front=True, attach=Image.Attach.BOTTOM_CENTER, diff --git a/dist/ba_data/python/baclassic/_appmode.py b/dist/ba_data/python/baclassic/_appmode.py index 840ee91..6049984 100644 --- a/dist/ba_data/python/baclassic/_appmode.py +++ b/dist/ba_data/python/baclassic/_appmode.py @@ -16,9 +16,11 @@ import babase from babase import AppMode import bauiv1 as bui from bauiv1 import builtinassets +from bauiv1 import stdassets from bauiv1lib.connectivity import wait_for_connectivity import _baclassic +import bascenev1 if TYPE_CHECKING: from typing import Callable, Any, Literal, Iterable @@ -83,10 +85,8 @@ class ClassicAppMode(AppMode): # AssetNameCompat in the native layer). Sourcing these from # the wrappers means a modder-swapped package keeps working. # (The bauiv1 and bascenev1 wrapper flavors carry identical - # __asset_package__ ids; builtinassets here is our module-level - # bauiv1 import.) - from bauiv1 import stdassets - + # __asset_package__ ids; builtinassets and stdassets here are + # our module-level bauiv1 imports.) babase.set_asset_name_compat_versions( { 'builtinassets': builtinassets.__asset_package__, @@ -97,6 +97,19 @@ class ClassicAppMode(AppMode): # Let the native layer do its thing. _baclassic.classic_app_mode_activate() + # Register the app-run's hosting package universe: exactly the + # set the launch metascan discovered (which construct-mode fully + # resolved before we could activate). Sessions we host may only + # reference packages from this set, and it's what we advertise + # to LAN scanners in v2 host-query responses. (Foundational + # rule: asset-packages.md decision #36.) + scanresults = babase.app.meta.scanresults + bascenev1.set_hosting_asset_packages( + sorted(scanresults.asset_packages) + if scanresults is not None + else [] + ) + app = bui.app plus = app.plus assert plus is not None @@ -252,23 +265,18 @@ class ClassicAppMode(AppMode): if item_id.startswith('tokens'): if item_id == 'tokens1': tokens = bacommon.classic.TOKENS1_COUNT - tokens_str = str(tokens) anim_time = 2.0 elif item_id == 'tokens2': tokens = bacommon.classic.TOKENS2_COUNT - tokens_str = str(tokens) anim_time = 2.5 elif item_id == 'tokens3': tokens = bacommon.classic.TOKENS3_COUNT - tokens_str = str(tokens) anim_time = 3.0 elif item_id == 'tokens4': tokens = bacommon.classic.TOKENS4_COUNT - tokens_str = str(tokens) anim_time = 3.5 else: tokens = 0 - tokens_str = '???' anim_time = 2.5 logging.warning( 'Unhandled item_id in on_purchase_process_end: %s', item_id @@ -282,12 +290,13 @@ class ClassicAppMode(AppMode): endvalue=self._last_tokens_value + tokens, ), clfx.Delay(anim_time), - clfx.LegacyScreenMessage( - message='You got ${COUNT} tokens!', - subs=['${COUNT}', tokens_str], + clfx.ScreenMessageV2( + message=stdassets.strings.economy.you_got_tokens( + tokens=tokens + ).spec, color=(0, 1, 0), ), - clfx.PlaySound(clfx.Sound.CASH_REGISTER), + clfx.PlaySoundV2(sound=builtinassets.audio.cash_register), ] bui.app.classic.run_bs_client_effects(effects) @@ -769,7 +778,7 @@ class ClassicAppMode(AppMode): ) def _root_ui_store_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.docui import DocUIWindow from bauiv1lib.store import StoreUIController @@ -785,7 +794,7 @@ class ClassicAppMode(AppMode): win_type=DocUIWindow, win_create_call=bui.CallStrict( StoreUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=btn, uiopenstateid='classicstore', ), @@ -833,7 +842,7 @@ class ClassicAppMode(AppMode): ResourceTypeInfoWindow('xp', origin_widget=btn) def _root_ui_inventory_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.docui import DocUIWindow from bauiv1lib.inventory import InventoryUIController @@ -843,7 +852,7 @@ class ClassicAppMode(AppMode): win_type=DocUIWindow, win_create_call=bui.CallStrict( InventoryUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=bui.get_special_widget('inventory_button'), uiopenstateid='classicinventory', ), @@ -987,10 +996,7 @@ class ClassicAppMode(AppMode): bui.WeakCallStrict(self._main_win_template_press), ), bui.DevConsoleButtonDef( - 'DocUI Test', bui.WeakCallStrict(self._doc_ui_test_press) - ), - bui.DevConsoleButtonDef( - 'DocUI Test v2', + 'DocUI Test', bui.WeakCallStrict(self._doc_ui_test_v2_press), ), ] @@ -1015,26 +1021,6 @@ class ClassicAppMode(AppMode): show_template_main_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), - ) - builtinassets.audio.error.get().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. - builtinassets.audio.swish.get().play() - - show_test_doc_ui_window() - def _doc_ui_test_v2_press(self) -> None: from bauiv1lib.docuitest import show_test_doc_ui_v2_window diff --git a/dist/ba_data/python/baclassic/_appsubsystem.py b/dist/ba_data/python/baclassic/_appsubsystem.py index d58c3d6..4f8cf2c 100644 --- a/dist/ba_data/python/baclassic/_appsubsystem.py +++ b/dist/ba_data/python/baclassic/_appsubsystem.py @@ -891,7 +891,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): # selected_profile: str | None = None, ) -> None: """Pop up a browser window from within a game.""" - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 # from bauiv1lib.profile.browser import ProfileBrowserWindow from bauiv1lib.inventory import InventoryUIController @@ -906,7 +906,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): babase.app.ui_v1.set_main_window( InventoryUIController(player_profiles_only=True).create_window( - dui1.Request('/'), + dui2.Request('/'), uiopenstateid='classicinventory', transition=transition, origin_widget=origin_widget, diff --git a/dist/ba_data/python/baclassic/_clienteffect.py b/dist/ba_data/python/baclassic/_clienteffect.py index 986d8d6..734c3f6 100644 --- a/dist/ba_data/python/baclassic/_clienteffect.py +++ b/dist/ba_data/python/baclassic/_clienteffect.py @@ -2,10 +2,13 @@ # """Functionality related to running client-effects from the master server.""" +import asyncio import logging +from functools import partial from typing import TYPE_CHECKING, assert_never -from efro.util import strict_partial +from efro.util import strict_partial, strip_exception_tracebacks +from bacommon.logging import ClientLoggerName import bauiv1 @@ -13,12 +16,96 @@ import _baclassic if TYPE_CHECKING: import bacommon.clienteffect as clfx + from bacommon.langstr import LanguageStringNameDecodeContext + +#: How long we wait on the asset-package resolve for v2 effects before +#: giving up (effects are decorative; skipping beats hanging). +_RESOLVE_TIMEOUT_SECONDS = 30.0 + +assetslog = logging.getLogger(ClientLoggerName.ASSETS.value) def run_bs_client_effects( effects: list[clfx.Effect], delay: float = 0.0 ) -> None: """Run effects.""" + import bacommon.clienteffect as clfx + + # V2 effect forms reference asset-packages (l-string text, sound + # refs). Those need resolving — possibly downloading — before the + # effects can run; kick that off and run once ready. Effects with + # no package refs run immediately as always. + apverids: set[str] = set() + clfx.collect_apverids(effects, apverids) + if not apverids: + _run_effects(effects, delay=delay) + return + bauiv1.app.create_async_task( + _resolve_and_run_effects(effects, sorted(apverids), delay) + ) + + +async def _resolve_and_run_effects( + effects: list[clfx.Effect], apverids: list[str], delay: float +) -> None: + """Resolve referenced asset-packages then run the effects. + + Runs as a logic-thread async task; the per-locale string reads do + blocking file IO so they hop through the loop's executor. + """ + from bacommon.langstr import LanguageStringNameDecodeContext + + assert bauiv1.in_logic_thread() + + locale = bauiv1.app.locale.current_locale + try: + async with asyncio.timeout(_RESOLVE_TIMEOUT_SECONDS): + await bauiv1.app.assets.resolve(apverids, language=locale) + loop = asyncio.get_running_loop() + language = { + apverid: await loop.run_in_executor( + None, + partial( + bauiv1.app.assets.get_package_strings, + apverid, + locale, + ), + ) + for apverid in apverids + } + except TimeoutError as exc: + # Fail soft; effects are decorative. This can legitimately + # happen under poor connectivity, so it's info, not a warning. + assetslog.info( + 'Timed out resolving asset-packages %s for client-effects' + ' (%.0fs); skipping effects.', + apverids, + _RESOLVE_TIMEOUT_SECONDS, + ) + strip_exception_tracebacks(exc) + return + except Exception as exc: + # Fail soft; effects are decorative. + logging.warning( + 'Error resolving asset-packages for client-effects;' + ' skipping effects.', + exc_info=True, + ) + strip_exception_tracebacks(exc) + return + _run_effects( + effects, + delay=delay, + decodectx=LanguageStringNameDecodeContext(language, locale), + ) + + +def _run_effects( + effects: list[clfx.Effect], + *, + delay: float = 0.0, + decodectx: LanguageStringNameDecodeContext | None = None, +) -> None: # pylint: disable=too-many-branches import bacommon.clienteffect as clfx @@ -55,6 +142,39 @@ def run_bs_client_effects( ), ) + elif effecttype is clfx.EffectTypeID.SCREEN_MESSAGE_V2: + assert isinstance(effect, clfx.ScreenMessageV2) + if decodectx is None: + # Should be impossible; v2 effects imply a resolve + # pass happened (which builds the context). + logging.error( + 'Got ScreenMessageV2 effect with no decode context.' + ) + else: + bauiv1.apptimer( + delay, + strict_partial( + bauiv1.screenmessage, + decodectx.decode(effect.message), + color=effect.color, + literal=True, + ), + ) + + elif effecttype is clfx.EffectTypeID.SOUND_V2: + assert isinstance(effect, clfx.PlaySoundV2) + # The referenced package is resolved at this point, so the + # qualified ':' ref loads like any asset. + bauiv1.apptimer( + delay, + strict_partial( + bauiv1.getsound( + f'{effect.sound.apverid}:{effect.sound.name}' + ).play, + volume=effect.volume, + ), + ) + elif effecttype is clfx.EffectTypeID.SOUND: assert isinstance(effect, clfx.PlaySound) scls = clfx.Sound diff --git a/dist/ba_data/python/baclassic/_net.py b/dist/ba_data/python/baclassic/_net.py index 14a50ad..ee43656 100644 --- a/dist/ba_data/python/baclassic/_net.py +++ b/dist/ba_data/python/baclassic/_net.py @@ -40,7 +40,6 @@ class MasterServerV1CallThread(threading.Thread): callback: MasterServerCallback | None, response_type: MasterServerResponseType, ): - # pylint: disable=too-many-positional-arguments super().__init__() self._request = request diff --git a/dist/ba_data/python/baclassic/_servermode.py b/dist/ba_data/python/baclassic/_servermode.py index d45ff19..b72fced 100644 --- a/dist/ba_data/python/baclassic/_servermode.py +++ b/dist/ba_data/python/baclassic/_servermode.py @@ -439,6 +439,7 @@ class ServerController: bascenev1.set_public_party_max_size(self._config.max_party_size) bascenev1.set_public_party_queue_enabled(self._config.enable_queue) bascenev1.set_public_party_name(self._config.party_name) + bascenev1.set_host_password(self._config.password) bascenev1.set_public_party_stats_url(self._config.stats_url) bascenev1.set_public_party_public_address_ipv4( self._config.public_ipv4_address diff --git a/dist/ba_data/python/bacommon/app.py b/dist/ba_data/python/bacommon/app.py index cbc75d6..487d6ca 100644 --- a/dist/ba_data/python/bacommon/app.py +++ b/dist/ba_data/python/bacommon/app.py @@ -152,6 +152,13 @@ class AppVariant(Enum): #: checks or logging enabled). TEST_BUILD = 'test_build' + #: Server bundles we distribute for third-party operators (such as + #: those on the ballistica.net downloads page). + SERVER = 'server' + + #: Our own cloud-hosted game servers. + SERVER_BASN = 'server_basn' + # Various stores. AMAZON_APPSTORE = 'amazon_appstore' GOOGLE_PLAY = 'google_play' diff --git a/dist/ba_data/python/bacommon/assetcas.py b/dist/ba_data/python/bacommon/assetcas.py index 1da2d25..0e5bdab 100644 --- a/dist/ba_data/python/bacommon/assetcas.py +++ b/dist/ba_data/python/bacommon/assetcas.py @@ -10,6 +10,7 @@ root, and owns concurrency, retry, and progress reporting itself. """ import os +import json import base64 import hashlib import tempfile @@ -49,6 +50,27 @@ def encode_asset_token(token: securedata.Archive) -> str: ) +def parse_flavor_manifest_blobs(data: bytes | str) -> dict[str, int]: + """Extract a flavor-manifest's data-blob map from its canonical JSON. + + Parses the ``{"e": {logical_path: {part: {"h", "s"}}}}`` shape and + returns every referenced data blob as a content-sha256-hex -> + canonical-byte-size map, in manifest order (the order clients + download). The single shared reader for this shape -- resolve + grant-building, ``/casblob`` scope verification, and tests all go + through it. Raises :class:`ValueError` on a malformed manifest. + """ + try: + parsed = json.loads(data) + blobs: dict[str, int] = {} + for entry in parsed['e'].values(): + for comp in entry.values(): + blobs[comp['h']] = comp['s'] + except (KeyError, TypeError, AttributeError) as exc: + raise ValueError(f'malformed flavor-manifest: {exc}') from exc + return blobs + + def cas_blob_path(root: str, filehash: str) -> str: """Return the path a CAS blob occupies under a cache root. diff --git a/dist/ba_data/python/bacommon/assetref/__init__.py b/dist/ba_data/python/bacommon/assetref/__init__.py index 5ed0b0f..1d917f4 100644 --- a/dist/ba_data/python/bacommon/assetref/__init__.py +++ b/dist/ba_data/python/bacommon/assetref/__init__.py @@ -1,28 +1,33 @@ # Released under the MIT License. See LICENSE for details. # -"""Language-independent references to assets within asset-packages. +"""Asset *specs*: authoring-level references to asset-package assets. -A reference is a minimal pointer -- an ``apverid`` plus an asset's logical -name -- carrying no asset data. The server (bamaster) holds only references; -the client resolves the package and loads the real asset for display. Each -kind gets a distinct type (:class:`TextureRef`, :class:`MeshRef`) so a -consumer schema can enforce where each kind may go. +A spec is a minimal claim about an asset -- an ``apverid`` plus a +logical name -- carrying no asset data and no guarantee the package is +locally present (or still exists). Per the D28 semantic split (see +``strings-asset-migration.md`` in ballistica-internal), assets ladder +through three tiers: ``TextureSpec`` (this claim form; wire/model +currency) -> client ``bauiv1.TextureRef`` (a verified-local subclass +adding ``.get()``; its wrapper pin resolved before use) -> +``bauiv1.Texture`` (the loaded engine asset). Servers hold only specs; +consuming clients verify/resolve before display. Each kind gets a +distinct type (:class:`TextureSpec`, :class:`MeshSpec`) so a consumer +schema can enforce where each kind may go. Type-safe, ergonomic access to a package's references comes from a generated -wrapper module (see :func:`generate_asset_ref_wrapper_module`), whose -per-kind roots (``textures``, ``meshes``) are driven at runtime by -:class:`AssetRefDir` -- mirroring the client-side asset-package wrappers. +wrapper module (emitted server-side; the codegen lives in +``baserver.assetwrappergen``), whose per-kind roots (``textures``, ``meshes``) +are driven at runtime by :class:`AssetRefDir` -- mirroring the client-side +asset-package wrappers. """ -from bacommon.assetref._core import TextureRef, MeshRef, SoundRef +from bacommon.assetref._core import TextureSpec, MeshSpec, SoundSpec from bacommon.assetref._wrapper import AssetRefDir, AssetRefTree -from bacommon.assetref._codegen import generate_asset_ref_wrapper_module __all__ = [ - 'TextureRef', - 'MeshRef', - 'SoundRef', + 'TextureSpec', + 'MeshSpec', + 'SoundSpec', 'AssetRefDir', 'AssetRefTree', - 'generate_asset_ref_wrapper_module', ] diff --git a/dist/ba_data/python/bacommon/assetref/_core.py b/dist/ba_data/python/bacommon/assetref/_core.py index 0caa96d..2b0ab40 100644 --- a/dist/ba_data/python/bacommon/assetref/_core.py +++ b/dist/ba_data/python/bacommon/assetref/_core.py @@ -8,7 +8,7 @@ the asset's logical ``name`` (e.g. ``textures/zoe_icon``). It carries no asset *data* -- the server (bamaster) only ever holds the reference; the client resolves the package and loads the actual asset for display. -Each asset kind gets its own type (:class:`TextureRef`, :class:`MeshRef`, +Each asset kind gets its own type (:class:`TextureSpec`, :class:`MeshSpec`, ...) so that a consumer schema can enforce *where* each kind may go -- a texture-typed field rejects a mesh and vice versa. The types share an identical shape but are deliberately distinct classes for that reason. @@ -27,7 +27,7 @@ from efro.dataclassio import ioprepped, IOAttrs @ioprepped @dataclass -class TextureRef: +class TextureSpec: """A language-independent reference to a texture in an asset-package. ``name`` is the texture's logical path within the package (e.g. @@ -41,7 +41,7 @@ class TextureRef: @ioprepped @dataclass -class MeshRef: +class MeshSpec: """A language-independent reference to a mesh in an asset-package. ``name`` is the mesh's logical path within the package (e.g. @@ -55,7 +55,7 @@ class MeshRef: @ioprepped @dataclass -class SoundRef: +class SoundSpec: """A language-independent reference to a sound in an asset-package. ``name`` is the sound's logical path within the package (e.g. diff --git a/dist/ba_data/python/bacommon/assetref/_wrapper.py b/dist/ba_data/python/bacommon/assetref/_wrapper.py index cdc35f7..e9d41f9 100644 --- a/dist/ba_data/python/bacommon/assetref/_wrapper.py +++ b/dist/ba_data/python/bacommon/assetref/_wrapper.py @@ -10,13 +10,13 @@ side: a leaf reads as a property yielding the kind's reference type, a subdir is a nested :class:`AssetRefDir`. This mirrors the client-side asset wrappers (``bauiv1._assetwrap.AssetDir``) -except it yields a language-independent *reference* (:class:`TextureRef` / -:class:`MeshRef`) rather than loading the actual engine asset -- so the +except it yields a language-independent *reference* (:class:`TextureSpec` / +:class:`MeshSpec`) rather than loading the actual engine asset -- so the same ergonomics (``pkg.textures.zoe_icon``) work server-side where no real assets exist. """ -from bacommon.assetref._core import TextureRef, MeshRef, SoundRef +from bacommon.assetref._core import TextureSpec, MeshSpec, SoundSpec #: A node in a wrapper's kind-code tree: each key is one path segment; a #: ``dict`` value is a subdirectory and a ``str`` value is a leaf asset @@ -43,7 +43,7 @@ class AssetRefDir: def __getattr__( self, name: str - ) -> 'AssetRefDir | TextureRef | MeshRef | SoundRef': + ) -> 'AssetRefDir | TextureSpec | MeshSpec | SoundSpec': try: child = self._node[name] except KeyError: @@ -56,12 +56,12 @@ class AssetRefDir: def _make( apverid: str, path: str, kind: str -) -> TextureRef | MeshRef | SoundRef: +) -> TextureSpec | MeshSpec | SoundSpec: """Build a single leaf reference by its single-char kind code.""" if kind == 't': - return TextureRef(apverid, path) + return TextureSpec(apverid, path) if kind == 'm': - return MeshRef(apverid, path) + return MeshSpec(apverid, path) if kind == 's': - return SoundRef(apverid, path) + return SoundSpec(apverid, path) raise ValueError(f'Invalid asset-ref kind {kind!r} for {apverid}:{path}.') diff --git a/dist/ba_data/python/bacommon/bacloud.py b/dist/ba_data/python/bacommon/bacloud.py index 6fff64f..62501b7 100644 --- a/dist/ba_data/python/bacommon/bacloud.py +++ b/dist/ba_data/python/bacommon/bacloud.py @@ -638,6 +638,20 @@ class ResponseData: securedata.Archive | None, IOAttrs('tk', store_default=False) ] = None + #: The flavor-manifest blobs backing ``blobs`` + #: (content-sha256 -> canonical byte size). Sent by the master + #: so the intercepting basn node can mint a fleet-portable + #: capability token whose scope any node can verify (see + #: ``baserver.assetcap.AssetCapabilityPayload.fm_digests``); + #: the node consumes it and strips it from the response, so it + #: never reaches the bacloud client. Empty from pre-scope + #: masters (and, during rollout, when the basn fleet floor + #: predates scope support). + flavor_manifest_blobs: Annotated[ + dict[str, int], + IOAttrs('fmb', store_default=False, soft_default_factory=dict), + ] = field(default_factory=dict) + #: DEPRECATED / UNUSED -- always empty. Compression is no longer #: carried here: a blob's transfer encoding is negotiated per #: ``/casblob`` request (the node reports it via its diff --git a/dist/ba_data/python/bacommon/clienteffect.py b/dist/ba_data/python/bacommon/clienteffect.py index 2d3a616..171494c 100644 --- a/dist/ba_data/python/bacommon/clienteffect.py +++ b/dist/ba_data/python/bacommon/clienteffect.py @@ -15,6 +15,15 @@ from typing import Annotated, override, assert_never from efro.dataclassio import ioprepped, IOAttrs, IOMultiType +from bacommon import langstr +from bacommon.langstr import LangStrSpec +from bacommon.assetref import SoundSpec + +#: First engine build carrying the v2 client-effect machinery +#: (``ScreenMessageV2``/``PlaySoundV2`` + resolve-before-run). +#: Servers use this to emit the right form per client build. +V2_EFFECTS_MIN_BUILD = 22931 + class EffectTypeID(Enum): """Type ID for each of our subclasses.""" @@ -22,7 +31,9 @@ class EffectTypeID(Enum): UNKNOWN = 'u' LEGACY_SCREEN_MESSAGE = 'm' SCREEN_MESSAGE = 'sm' + SCREEN_MESSAGE_V2 = 'sm2' SOUND = 's' + SOUND_V2 = 's2' DELAY = 'd' CHEST_WAIT_TIME_ANIMATION = 't' TICKETS_ANIMATION = 'ta' @@ -63,8 +74,12 @@ class Effect(IOMultiType[EffectTypeID]): return LegacyScreenMessage if type_id is t.SCREEN_MESSAGE: return ScreenMessage + if type_id is t.SCREEN_MESSAGE_V2: + return ScreenMessageV2 if type_id is t.SOUND: return PlaySound + if type_id is t.SOUND_V2: + return PlaySoundV2 if type_id is t.DELAY: return Delay if type_id is t.CHEST_WAIT_TIME_ANIMATION: @@ -101,7 +116,7 @@ class Unknown(Effect): class LegacyScreenMessage(Effect): """Display a screen-message (Legacy version). - This will be processed as an Lstr with translation category + This will be processed as a legacy client Lstr with translation category 'serverResponses'. When possible, migrate to using :class:`ScreenMessage`. @@ -129,7 +144,7 @@ class ScreenMessage(Effect): 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 + to happen server-side). Pass a LangStrSpec json string and set is_lstr=True for client-side translation. """ @@ -145,6 +160,31 @@ class ScreenMessage(Effect): return EffectTypeID.SCREEN_MESSAGE +@ioprepped +@dataclass +class ScreenMessageV2(Effect): + """Display a screen-message (asset-package l-string version). + + The message is a language-agnostic + :class:`~bacommon.langstr.LangStrSpec`; the client resolves the referenced + asset-package(s) in its own locale and decodes before display (see + :func:`collect_apverids`). Only understood by clients new enough to + carry the v2 effect machinery — older ones drop it as + :class:`Unknown` — so gate on engine build or dual-send with a + legacy form where the message matters. + """ + + message: Annotated[LangStrSpec, IOAttrs('m')] + 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.SCREEN_MESSAGE_V2 + + class Sound(Enum): """Sounds that can be played.""" @@ -169,6 +209,44 @@ class PlaySound(Effect): return EffectTypeID.SOUND +@ioprepped +@dataclass +class PlaySoundV2(Effect): + """Play a sound from an asset-package. + + Unlike :class:`PlaySound`'s fixed :class:`Sound` set, this can play + any packaged sound via a typed + :class:`~bacommon.assetref.SoundSpec`; the client resolves the + referenced asset-package before playing (see + :func:`collect_apverids`). Only understood by clients new enough to + carry the v2 effect machinery — older ones drop it as + :class:`Unknown`. + """ + + sound: Annotated[SoundSpec, IOAttrs('s')] + volume: Annotated[float, IOAttrs('v', store_default=False)] = 1.0 + + @override + @classmethod + def get_type_id(cls) -> EffectTypeID: + return EffectTypeID.SOUND_V2 + + +def collect_apverids(effects: list[Effect], acc: set[str]) -> None: + """Gather every asset-package-version a list of effects references. + + The v2 effect forms are self-describing (name-based ``LangStrSpec`` values + and typed asset refs), so the packages a client must resolve before + running the effects are derived by walking them — nothing extra + rides the wire. Mirrors the doc-ui-v2 pattern. + """ + for effect in effects: + if isinstance(effect, ScreenMessageV2): + langstr.collect_apverids(effect.message, acc) + elif isinstance(effect, PlaySoundV2): + acc.add(effect.sound.apverid) + + @ioprepped @dataclass class ChestWaitTimeAnimation(Effect): diff --git a/dist/ba_data/python/bacommon/cloud.py b/dist/ba_data/python/bacommon/cloud.py index efeb3b5..5b9c96b 100644 --- a/dist/ba_data/python/bacommon/cloud.py +++ b/dist/ba_data/python/bacommon/cloud.py @@ -426,6 +426,12 @@ class AssetPackageResolveError(Enum): #: must update. Clients predating the build-number field also land #: here. CLIENT_TOO_OLD = 'tooold' + #: The package's own source content failed to build — a problem the + #: package author can fix (e.g. a malformed sound or texture file). + #: The human-readable ``error`` names the offending source file(s); + #: clients should surface it verbatim. Old clients see this as + #: ``INTERNAL`` via ``enum_fallback``. + CONTENT = 'content' class AssetPackageBuildPhase(Enum): diff --git a/dist/ba_data/python/bacommon/cloudfilecodec.py b/dist/ba_data/python/bacommon/cloudfilecodec.py index 842669c..0e7d58e 100644 --- a/dist/ba_data/python/bacommon/cloudfilecodec.py +++ b/dist/ba_data/python/bacommon/cloudfilecodec.py @@ -16,11 +16,14 @@ stored bytes *are* the canonical content. """ from enum import Enum +from functools import cache from typing import TYPE_CHECKING, assert_never if TYPE_CHECKING: from typing import Iterable + from compression.zstd import ZstdDict + class CompressionType(Enum): """How a blob's stored bytes are compressed. @@ -96,6 +99,26 @@ def all_compression_types() -> set[CompressionType]: return set(CompressionType) +@cache +def _shared_zstd_dict(dict_bytes: bytes) -> ZstdDict: + """Return a process-wide shared ``ZstdDict`` for a dictionary's bytes. + + Constructing a ``ZstdDict`` copies and digests the full dictionary, + so build each distinct dictionary exactly once per process instead + of once per (de)compress call — clients decompressing blobs and + servers compressing them both funnel through here many times with + the same dictionary. IMPORTANT: any *future* pre-shared dictionary + added to this codec should likewise go through this helper rather + than calling ``ZstdDict()`` directly. Cache keys are the dictionary + bytes themselves; sources should hand us a stable cached bytes + object (see :func:`bacommon.meshzstddict.display_mesh_dict_v1`) so + the hash is computed once and entries never duplicate. + """ + from compression import zstd + + return zstd.ZstdDict(dict_bytes) + + def zstd_compress(data: bytes, level: int) -> bytes: """Compress ``data`` with plain zstd at the given level.""" from compression import zstd @@ -116,7 +139,9 @@ def zstd_compress_with_dict( """Compress ``data`` with zstd using a pre-shared dictionary.""" from compression import zstd - return zstd.compress(data, level=level, zstd_dict=zstd.ZstdDict(dict_bytes)) + return zstd.compress( + data, level=level, zstd_dict=_shared_zstd_dict(dict_bytes) + ) def zstd_decompress_with_dict(data: bytes, dict_bytes: bytes) -> bytes: @@ -127,7 +152,7 @@ def zstd_decompress_with_dict(data: bytes, dict_bytes: bytes) -> bytes: """ from compression import zstd - return zstd.decompress(data, zstd_dict=zstd.ZstdDict(dict_bytes)) + return zstd.decompress(data, zstd_dict=_shared_zstd_dict(dict_bytes)) def compress_for_type( diff --git a/dist/ba_data/python/bacommon/docui/__init__.py b/dist/ba_data/python/bacommon/docui/__init__.py index 9d72511..c61f2f6 100644 --- a/dist/ba_data/python/bacommon/docui/__init__.py +++ b/dist/ba_data/python/bacommon/docui/__init__.py @@ -7,6 +7,7 @@ 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.langstr import WrapParams from bacommon.docui._docui import ( DocUIRequest, DocUIRequestTypeID, @@ -27,4 +28,5 @@ __all__ = [ 'UnknownDocUIResponse', 'DocUIWebRequest', 'DocUIWebResponse', + 'WrapParams', ] diff --git a/dist/ba_data/python/bacommon/docui/v1.py b/dist/ba_data/python/bacommon/docui/v1.py index d30a50f..1505229 100644 --- a/dist/ba_data/python/bacommon/docui/v1.py +++ b/dist/ba_data/python/bacommon/docui/v1.py @@ -10,6 +10,7 @@ from efro.dataclassio import ioprepped, IOAttrs, IOMultiType import bacommon.displayitem as ditm import bacommon.clienteffect as clfx +from bacommon.langstr import WrapParams from bacommon.docui._docui import ( DocUIRequest, DocUIRequestTypeID, @@ -336,6 +337,18 @@ class Text(Decoration): is_lstr: Annotated[bool, IOAttrs('l', store_default=False)] = False + #: The text field holds a language-string (canonical resource-form + #: wire JSON) to be evaluated natively at display and + #: re-evaluated on language changes. Set by the client-side v2 + #: transcode; mutually exclusive with is_lstr. + is_langstr: Annotated[bool, IOAttrs('ls', store_default=False)] = False + + #: Line-wrap constraints applied to the text at widget-creation + #: time. Set only by the client-local v2→v1 transcode; v1 producers + #: must never send it (older clients can't parse unknown fields — + #: bake newlines into the string instead). + wrap: Annotated[WrapParams | None, IOAttrs('w', store_default=False)] = None + highlight: Annotated[bool, IOAttrs('h', store_default=False)] = True depth_range: Annotated[tuple[float, float] | None, IOAttrs('z')] = None @@ -485,6 +498,23 @@ class Button: None ) label_is_lstr: Annotated[bool, IOAttrs('ll', store_default=False)] = False + + #: The label field holds a language-string (canonical resource-form + #: wire JSON) to be evaluated natively at display and + #: re-evaluated on language changes. Set by the client-side v2 + #: transcode; mutually exclusive with label_is_lstr. + label_is_langstr: Annotated[bool, IOAttrs('lls', store_default=False)] = ( + False + ) + + #: Line-wrap constraints applied to the label at widget-creation + #: time. Set only by the client-local v2→v1 transcode; v1 producers + #: must never send it (older clients can't parse unknown fields — + #: bake newlines into the label instead). + label_wrap: Annotated[ + WrapParams | None, IOAttrs('lw', store_default=False) + ] = None + 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 @@ -611,6 +641,23 @@ class ButtonRow(Row): float | None, IOAttrs('ts', store_default=False) ] = None title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + + #: The title field holds a language-string (canonical resource-form + #: wire JSON) to be evaluated natively at display and + #: re-evaluated on language changes. Set by the client-side v2 + #: transcode; mutually exclusive with title_is_lstr. + title_is_langstr: Annotated[bool, IOAttrs('tls', store_default=False)] = ( + False + ) + + #: Line-wrap constraints applied to the title at widget-creation + #: time. Set only by the client-local v2→v1 transcode; v1 producers + #: must never send it (older clients can't parse unknown fields — + #: bake newlines into the title instead). + title_wrap: Annotated[ + WrapParams | None, IOAttrs('tw', store_default=False) + ] = None + subtitle: Annotated[str | None, IOAttrs('s', store_default=False)] = None subtitle_color: Annotated[ tuple[float, float, float, float] | None, @@ -626,6 +673,22 @@ class ButtonRow(Row): False ) + #: The subtitle field holds a language-string (canonical resource-form + #: wire JSON) to be evaluated natively at display and + #: re-evaluated on language changes. Set by the client-side v2 + #: transcode; mutually exclusive with subtitle_is_lstr. + subtitle_is_langstr: Annotated[ + bool, IOAttrs('sls', store_default=False) + ] = False + + #: Line-wrap constraints applied to the subtitle at widget-creation + #: time. Set only by the client-local v2→v1 transcode; v1 producers + #: must never send it (older clients can't parse unknown fields — + #: bake newlines into the subtitle instead). + subtitle_wrap: Annotated[ + WrapParams | None, IOAttrs('sw', store_default=False) + ] = None + #: Spacing between all buttons in the row. button_spacing: Annotated[float, IOAttrs('bs', store_default=False)] = 15.0 @@ -695,6 +758,22 @@ class Page: #: allow client-side translation. title_is_lstr: Annotated[bool, IOAttrs('tl', store_default=False)] = False + #: The title field holds a language-string (canonical resource-form + #: wire JSON) to be evaluated natively at display and + #: re-evaluated on language changes. Set by the client-side v2 + #: transcode; mutually exclusive with title_is_lstr. + title_is_langstr: Annotated[bool, IOAttrs('tls', store_default=False)] = ( + False + ) + + #: Line-wrap constraints applied to the title at widget-creation + #: time. Set only by the client-local v2→v1 transcode; v1 producers + #: must never send it (older clients can't parse unknown fields — + #: bake newlines into the title instead). + title_wrap: Annotated[ + WrapParams | None, IOAttrs('tw', store_default=False) + ] = None + 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 diff --git a/dist/ba_data/python/bacommon/docui/v2.py b/dist/ba_data/python/bacommon/docui/v2.py index f8d33a6..83a0605 100644 --- a/dist/ba_data/python/bacommon/docui/v2.py +++ b/dist/ba_data/python/bacommon/docui/v2.py @@ -5,13 +5,13 @@ Where v1 carries pre-localized raw ``str`` text (optionally a JSON-encoded legacy ``babase.Lstr`` via ``*_is_lstr`` flags) and expects the *server* to localize, v2 text is always a language-agnostic -:class:`~bacommon.langstr.Lstr`. The server ships one response to every +:class:`~bacommon.langstr.LangStrSpec`. The server ships one response to every client regardless of language; the client resolves the referenced asset-packages in its own locale and decodes the strings at render time. See ``docs/initiatives/docui-v2-lstrings.md`` (ballistica-internal). This is the milestone-1 slice: a minimal but real subset of the v1 element set, with -text typed as ``Lstr`` (the name-based form -- subs are flat for now). +text typed as ``LangStrSpec`` (the name-based form -- subs are flat for now). Non-text fields mirror v1's names/keys so client render code can stay close to ``v1prep``. """ @@ -22,8 +22,10 @@ from typing import Annotated, override, assert_never from efro.dataclassio import ioprepped, IOAttrs, IOMultiType -from bacommon.langstr import Lstr -from bacommon.assetref import TextureRef, MeshRef +import bacommon.clienteffect as clfx +import bacommon.displayitem as ditm +from bacommon.langstr import LangStrSpec +from bacommon.assetref import TextureSpec, MeshSpec from bacommon.docui._docui import ( DocUIRequest, DocUIRequestTypeID, @@ -161,6 +163,26 @@ class Local(Action): #: Plays a swish if closing the window, else a click. default_sound: Annotated[bool, IOAttrs('ds', store_default=False)] = True + #: Client-effects to run immediately when the button is pressed. + #: Note that effect payloads are not yet v2-native — text in them is + #: raw/legacy-lstr, pending clienteffect gaining a resolve-context + #: concept (see the SoundSpec followup in docs/followups.md). + #: + #: :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: @@ -189,6 +211,7 @@ class DecorationTypeID(Enum): UNKNOWN = 'u' TEXT = 't' IMAGE = 'i' + DISPLAY_ITEM = 'd' class Decoration(IOMultiType[DecorationTypeID]): @@ -210,6 +233,8 @@ class Decoration(IOMultiType[DecorationTypeID]): return Text if type_id is t.IMAGE: return Image + if type_id is t.DISPLAY_ITEM: + return DisplayItem assert_never(type_id) @override @@ -237,13 +262,17 @@ class UnknownDecoration(Decoration): @ioprepped @dataclass class Text(Decoration): - """Text decoration. ``text`` is a language-agnostic :class:`Lstr`.""" + """Text decoration. - text: Annotated[Lstr, IOAttrs('t')] + ``text`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. + """ + + text: Annotated[LangStrSpec, IOAttrs('t')] position: Annotated[tuple[float, float], IOAttrs('p')] #: Effectively 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 @@ -275,11 +304,12 @@ class Image(Decoration): """Image decoration. Textures/meshes are language-independent refs. Unlike text, image assets need no per-locale decode; each ref - (:class:`TextureRef` / :class:`MeshRef`) is resolved by the client and + (:class:`~bacommon.assetref.TextureSpec` / + :class:`~bacommon.assetref.MeshSpec`) is resolved by the client and rendered directly. """ - texture: Annotated[TextureRef, IOAttrs('t')] + texture: Annotated[TextureSpec, IOAttrs('t')] position: Annotated[tuple[float, float], IOAttrs('p')] size: Annotated[tuple[float, float], IOAttrs('s')] color: Annotated[ @@ -293,7 +323,7 @@ class Image(Decoration): VAlign.CENTER ) tint_texture: Annotated[ - TextureRef | None, IOAttrs('tt', store_default=False) + TextureSpec | None, IOAttrs('tt', store_default=False) ] = None tint_color: Annotated[ tuple[float, float, float] | None, IOAttrs('tc1', store_default=False) @@ -302,13 +332,13 @@ class Image(Decoration): tuple[float, float, float] | None, IOAttrs('tc2', store_default=False) ] = None mask_texture: Annotated[ - TextureRef | None, IOAttrs('mt', store_default=False) + TextureSpec | None, IOAttrs('mt', store_default=False) ] = None mesh_opaque: Annotated[ - MeshRef | None, IOAttrs('mo', store_default=False) + MeshSpec | None, IOAttrs('mo', store_default=False) ] = None mesh_transparent: Annotated[ - MeshRef | None, IOAttrs('mn', store_default=False) + MeshSpec | 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 @@ -319,6 +349,50 @@ class Image(Decoration): return DecorationTypeID.IMAGE +class DisplayItemStyle(Enum): + """Styles a display-item can be drawn in (mirrors v1).""" + + #: Fully conveys what the item is. Draws in a 4x3 box and works + #: best with large-ish displays. + FULL = 'f' + + #: Fully conveys the item, condensed into a 2x1 box for small sizes. + COMPACT = 'c' + + #: Graphics-only representation in a 1x1 box, for use alongside a + #: textual description. + ICON = 'i' + + +@ioprepped +@dataclass +class DisplayItem(Decoration): + """DisplayItem decoration. + + The wrapped :class:`~bacommon.displayitem.Wrapper` already + localizes its own text client-side, so it carries over from v1 + unchanged. + """ + + 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.""" @@ -336,12 +410,16 @@ class ButtonStyle(Enum): @ioprepped @dataclass class Button: - """A button in our doc-ui. ``label`` is a language-agnostic :class:`Lstr`. + """A button in our doc-ui. + ``label`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. Size, padding, and all decorations scale consistently with ``scale``. """ - label: Annotated[Lstr | None, IOAttrs('l', store_default=False)] = None + label: Annotated[LangStrSpec | 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) @@ -357,8 +435,11 @@ class Button: label_scale: Annotated[float | None, IOAttrs('ls', store_default=False)] = ( None ) + label_flatness: Annotated[ + float | None, IOAttrs('lf', store_default=False) + ] = None texture: Annotated[ - TextureRef | None, IOAttrs('tex', store_default=False) + TextureSpec | 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 @@ -373,6 +454,18 @@ class Button: ) default: Annotated[bool, IOAttrs('df', store_default=False)] = False selected: Annotated[bool, IOAttrs('sel', store_default=False)] = False + + icon: Annotated[TextureSpec | 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 @@ -435,20 +528,52 @@ class UnknownRow(Row): @ioprepped @dataclass class ButtonRow(Row): - """A row consisting of buttons. ``title``/``subtitle`` are :class:`Lstr`.""" + """A row consisting of buttons. + + ``title``/``subtitle`` are :class:`~bacommon.langstr.LangStrSpec`. + """ buttons: Annotated[list[Button], IOAttrs('b')] - title: Annotated[Lstr | None, IOAttrs('t', store_default=False)] = None + 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 + + title: Annotated[LangStrSpec | None, IOAttrs('t', store_default=False)] = ( + None + ) title_color: Annotated[ tuple[float, float, float, float] | None, IOAttrs('tc', store_default=False), ] = None - subtitle: Annotated[Lstr | None, IOAttrs('s', 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 + + subtitle: Annotated[ + LangStrSpec | 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 #: Spacing between all buttons in the row. button_spacing: Annotated[float, IOAttrs('bs', store_default=False)] = 15.0 @@ -458,6 +583,12 @@ class ButtonRow(Row): padding_top: Annotated[float, IOAttrs('pt', store_default=False)] = 10.0 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 @@ -478,9 +609,12 @@ class ButtonRow(Row): @ioprepped @dataclass class Page: - """Doc-UI page version 2. ``title`` is a language-agnostic :class:`Lstr`.""" + """Doc-UI page version 2. - title: Annotated[Lstr, IOAttrs('t')] + ``title`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. + """ + + title: Annotated[LangStrSpec, IOAttrs('t')] rows: Annotated[list[Row], IOAttrs('r')] #: Center content vertically when it's smaller than the available height. @@ -525,6 +659,55 @@ class Response(DocUIResponse): ResponseStatus.SUCCESS ) + #: The engine build this response was built for, as a sanity check: + #: responses can be tailored per-build (client-effect forms etc.), + #: so a consumer seeing a mismatch with its own build should treat + #: the response as stale (e.g. toss cached data) rather than use it. + for_build: Annotated[int | None, IOAttrs('fb', store_default=False)] = None + + #: Asset-package-versions the response's integer-indexed + #: language-strings resolve against (position = package index). + #: Present only on wire responses finalized to the indexed form by + #: the server; its presence declares the page + contained client + #: effects fully indexed (consumers may flag resource-form leaks), + #: and it doubles as the client's resolve/pre-warm manifest. + #: Locally-authored responses never carry it (indexing is a wire + #: compression; local pages stay in the authored resource form). + packages: Annotated[list[str], IOAttrs('pk', store_default=False)] = field( + default_factory=list + ) + + #: Effects to run on the client when this response is initially + #: received (not re-run on automatic page refreshes). Note that + #: effect payloads are not yet v2-native — text in them is + #: raw/legacy-lstr, pending clienteffect gaining a resolve-context + #: concept (see the SoundSpec followup in docs/followups.md). + #: + #: :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 + #: (not re-run on automatic page refreshes). Will be handled by + #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. + 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. minimum_engine_build: Annotated[ int | None, IOAttrs('b', store_default=False) diff --git a/dist/ba_data/python/bacommon/langstr/__init__.py b/dist/ba_data/python/bacommon/langstr/__init__.py index 8a723f1..66d4704 100644 --- a/dist/ba_data/python/bacommon/langstr/__init__.py +++ b/dist/ba_data/python/bacommon/langstr/__init__.py @@ -1,10 +1,22 @@ # Released under the MIT License. See LICENSE for details. # -"""Language-agnostic complex strings -- the ``Lstr2`` runtime model. +"""Language-agnostic complex strings -- the ``LangStrSpec`` authoring model. -Pure-Python prototype of the language-string-context system (see -``docs/initiatives/language-string-context.md`` in ballistica-internal). The -C++ ``Lstr2`` is a later optimized port of this proven model. +"LangStr" is the name for this whole generation of string handling +(formerly working-titled ``Lstr2``); the legacy client translation class +keeps the ``Lstr`` name, which stays reserved for it to avoid ambiguity. +Within that generation the type split is semantic (see +``strings-asset-migration.md`` D28 in ballistica-internal): + +- ``LangStrSpec`` (here) is the *authoring spec* form -- a claim about a + string carrying no guarantee that its asset-package is locally present + (or even still exists). This is the currency for authoring surfaces and + wire/model dataclasses; consuming ends verify/resolve before display. +- The native client ``babase.LangStr`` (a later optimized C++ port of + this proven model) represents a *verified-local* string -- holding one + implies it is displayable there. Its ``.spec`` property projects back + to this form (always valid); there is deliberately no public + unverified->verified conversion. The model lets us pass around minimal, language-independent representations of a complex string (substitutions, plurals, nesting) and resolve to a flat @@ -13,7 +25,14 @@ serves clients of any language. """ from bacommon.langstr._core import ( - Lstr, + LangStrSpec, + WrapParams, + LangStrSpecResource, + LangStrSpecValue, + LangStrSpecResourceIndexed, + LangStrSpecTypeID, + LANGSTR_EXT_MIN_BUILD, + MAX_NESTING_DEPTH, StringDef, PackageDef, PackageStructure, @@ -21,14 +40,15 @@ from bacommon.langstr._core import ( LanguageStringDecodeContext, LanguageStringNameDecodeContext, LangStrError, - EncodedLstr, + EncodedLangStr, + contains_resource_form, + collect_apverids, ) from bacommon.langstr._wrapper import ( LangStrDir, WrapperTree, package_structure, ) -from bacommon.langstr._codegen import generate_wrapper_module from bacommon.langstr._blob import ( serialize_language_blob, parse_language_blob, @@ -36,7 +56,14 @@ from bacommon.langstr._blob import ( ) __all__ = [ - 'Lstr', + 'LangStrSpec', + 'WrapParams', + 'LangStrSpecResource', + 'LangStrSpecValue', + 'LangStrSpecResourceIndexed', + 'LangStrSpecTypeID', + 'LANGSTR_EXT_MIN_BUILD', + 'MAX_NESTING_DEPTH', 'StringDef', 'PackageDef', 'PackageStructure', @@ -44,11 +71,12 @@ __all__ = [ 'LanguageStringDecodeContext', 'LanguageStringNameDecodeContext', 'LangStrError', - 'EncodedLstr', + 'EncodedLangStr', + 'contains_resource_form', + 'collect_apverids', 'LangStrDir', 'WrapperTree', 'package_structure', - 'generate_wrapper_module', 'serialize_language_blob', 'parse_language_blob', 'LANGUAGE_BLOB_STRINGS_KEY', diff --git a/dist/ba_data/python/bacommon/langstr/_blob.py b/dist/ba_data/python/bacommon/langstr/_blob.py index def4280..137c11c 100644 --- a/dist/ba_data/python/bacommon/langstr/_blob.py +++ b/dist/ba_data/python/bacommon/langstr/_blob.py @@ -17,29 +17,48 @@ shape). A package ships one or the other. import json +from typing import TYPE_CHECKING + from efro.dataclassio import dataclass_to_dict, dataclass_from_dict from bacommon.loctext import StringSelector +if TYPE_CHECKING: + from bacommon.langstr._core import WrapParams + #: Top-level key the new-format strings live under (sibling to ``legacy``). LANGUAGE_BLOB_STRINGS_KEY = 'strings' -def serialize_language_blob(values: dict[str, str | StringSelector]) -> str: +def serialize_language_blob( + values: dict[str, str | StringSelector], + wraps: 'dict[str, WrapParams] | None' = None, +) -> str: """Serialize a per-locale value map to the canonical language blob. ``values`` maps each string's logical name to its value -- a plain - ``str`` or a :class:`StringSelector`. Output is deterministic (sorted - keys, fixed formatting) for cache stability and diffability. + ``str`` or a :class:`StringSelector`. ``wraps`` optionally maps + names to their definition-time :class:`WrapParams` (decision D-t); + a wrapped entry is emitted as a ``{'v': value, 'w': wrap}`` carrier + dict (which pre-wrap clients skip fail-soft). Output is + deterministic (sorted keys, fixed formatting) for cache stability + and diffability. """ + + def _encode(name: str, value: str | StringSelector) -> object: + out: object = ( + dataclass_to_dict(value) + if isinstance(value, StringSelector) + else value + ) + wrap = None if wraps is None else wraps.get(name) + if wrap is not None: + out = {'v': out, 'w': dataclass_to_dict(wrap)} + return out + return json.dumps( { LANGUAGE_BLOB_STRINGS_KEY: { - name: ( - dataclass_to_dict(value) - if isinstance(value, StringSelector) - else value - ) - for name, value in values.items() + name: _encode(name, value) for name, value in values.items() } }, ensure_ascii=False, @@ -65,6 +84,11 @@ def parse_language_blob(text: str) -> dict[str, str | StringSelector]: return {} out: dict[str, str | StringSelector] = {} for name, value in strings.items(): + # A {'v': ..., 'w': ...} carrier (decision D-t) holds the value + # plus its definition-time wrap hint; only the value matters + # here (the native tables read the wrap themselves). + if isinstance(value, dict) and 'v' in value: + value = value['v'] if isinstance(value, str): out[name] = value elif isinstance(value, dict): diff --git a/dist/ba_data/python/bacommon/langstr/_core.py b/dist/ba_data/python/bacommon/langstr/_core.py index 19902f4..d4c813e 100644 --- a/dist/ba_data/python/bacommon/langstr/_core.py +++ b/dist/ba_data/python/bacommon/langstr/_core.py @@ -5,10 +5,14 @@ See ``docs/initiatives/language-string-context.md`` (ballistica-internal) for the full design. Three pieces: -* :class:`Lstr` -- a deferred, language-agnostic complex string (an apverid - + a string name + keyword substitution values, each a flat ``str``/``int`` - or a nested :class:`Lstr`). -* :class:`LanguageStringEncodeContext` -- turns a batch of :class:`Lstr` into +* :class:`LangStrSpec` -- a deferred, language-agnostic complex string; a + small multitype whose forms are :class:`LangStrSpecResource` (apverid + + logical name + keyword substitutions), :class:`LangStrSpecValue` (a raw + literal), and :class:`LangStrSpecResourceIndexed` (the compact + integer-addressed projection). Substitution values are flat + ``str``/``int`` or nested language-strings. +* :class:`LanguageStringEncodeContext` -- turns a batch of + :class:`LangStrSpec` values into minimal, language-free encoded chunks plus the ``{pkg_int: apverid}`` map. * :class:`LanguageStringDecodeContext` -- single-locale; turns an encoded chunk back into a flat string via :func:`bacommon.loctext.evaluate`. @@ -16,14 +20,15 @@ for the full design. Three pieces: Error posture is deliberately asymmetric: encoding is the authoring side (you control the data) so it raises :class:`LangStrError` loudly; decoding is the consumer side (you receive data) so it is fail-visible -- it returns -an ``LSTR_ERROR:…`` sentinel and logs, never crashing the caller. +an ``LANGSTR_ERROR:…`` sentinel and logs, never crashing the caller. """ import logging +from enum import Enum from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, assert_never, override -from efro.dataclassio import ioprepped, IOAttrs +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType from bacommon.loctext import evaluate, LocTextError if TYPE_CHECKING: @@ -32,6 +37,50 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +#: Cap on nested-:class:`LangStrSpec` substitution depth at decode. Wire +#: data is untrusted, so the recursive decode paths refuse trees deeper +#: than this (fail-visible) instead of recursing unboundedly. +MAX_NESTING_DEPTH = 16 + + +@ioprepped +@dataclass +class WrapParams: + """Constraints for splitting a text value into lines client-side. + + Mirrors the engine's simple equal-width line splitter + (``babase.split_text_into_lines()``): text is broken only at valid + line-break opportunities, using the fewest lines that keep every + line within :attr:`max_chars_per_line` (when provided) while + staying between :attr:`min_lines` and :attr:`max_lines` (``None`` + means unlimited), with line lengths balanced within that count. So + ``max_chars_per_line`` alone gives basic wrapping and ``min_lines`` + alone gives an exact line count. Constraints are best-effort. + + **Default to pinning an exact line count**: set ``min_lines`` to + the layout's designed count and leave ``max_chars_per_line`` + unset. A ``max_chars_per_line``-driven wrap yields a per-locale + *varying* line count (translation lengths differ), which reads as + broken in layouts designed around a specific count — and every + legacy-converted string is such a layout, since the legacy + pipeline hand-baked newlines at fixed counts (see D21 in the + strings-asset-migration initiative). Reserve + ``max_chars_per_line`` for surfaces explicitly designed to + tolerate a variable number of lines. + + Per decision D-t these are *definition-time* presentation hints: a + string definition carries them optionally, they ride each locale + blob, and evaluation applies them automatically. They are + locale-invariant, and width-driven layout consumers may ignore + them (they are a fallback presentation default). + """ + + min_lines: Annotated[int, IOAttrs('mn', store_default=False)] = 1 + max_lines: Annotated[int | None, IOAttrs('mx', store_default=False)] = None + max_chars_per_line: Annotated[ + int | None, IOAttrs('mc', store_default=False) + ] = None + class LangStrError(Exception): """A malformed language-string or encode-context operation.""" @@ -41,42 +90,167 @@ class _DecodeFail(Exception): """Internal: a structural problem while decoding a chunk. Caught once in :meth:`LanguageStringDecodeContext.decode` and turned - into the fail-visible ``LSTR_ERROR:…`` sentinel. + into the fail-visible ``LANGSTR_ERROR:…`` sentinel. """ +class LangStrSpecTypeID(Enum): + """Type IDs for the :class:`LangStrSpec` multitype's forms.""" + + RESOURCE = 'r' + VALUE = 'v' + RESOURCE_INDEXED = 'i' + + +class LangStrSpec(IOMultiType[LangStrSpecTypeID]): + """A deferred, language-agnostic complex string. + + The base of a small multitype: a language-string is a + :class:`LangStrSpecResource` (an asset-package string addressed by + apverid + logical name -- the common authored form), a + :class:`LangStrSpecValue` (a raw literal that needs no package), or a + :class:`LangStrSpecResourceIndexed` (the compact integer-addressed + projection of a resource, for contexts that carry a package-index + map). All forms take keyword substitutions whose values may + themselves be language-strings, so a ``LangStrSpec`` is a recursive + tree; it holds tokens, not text, and only decodes to a flat string + in some particular locale at display time. + + Wire notes: the indexed form is the multitype *default*, so it + alone serializes without a type tag (it is the space-sensitive + form). Clients older than ``LANGSTR_EXT_MIN_BUILD`` understand only + tag-free resource values with flat subs; producers that know the + client build must gate everything beyond that (nested subs and the + value/indexed forms) on it. + """ + + @override + @classmethod + def get_type(cls, type_id: LangStrSpecTypeID) -> type[LangStrSpec]: + """Return the subclass for each of our type-ids.""" + t = LangStrSpecTypeID + if type_id is t.RESOURCE: + return LangStrSpecResource + if type_id is t.VALUE: + return LangStrSpecValue + if type_id is t.RESOURCE_INDEXED: + return LangStrSpecResourceIndexed + + # Make sure we cover all cases. + assert_never(type_id) + + @override + @classmethod + def get_type_id(cls) -> LangStrSpecTypeID: + # Child classes supply this themselves. + raise NotImplementedError() + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return 't' + + @override + @classmethod + def get_default_type_id(cls) -> LangStrSpecTypeID | None: + # The indexed form owns the tag-free slot: the tag would be + # large relative to its couple of ints, and it is chosen + # exactly when space matters. (Per dataclassio rules this + # default is permanent once keyless data ships.) + return LangStrSpecTypeID.RESOURCE_INDEXED + + @ioprepped @dataclass -class Lstr: - """A deferred, language-agnostic complex string. +class LangStrSpecResource(LangStrSpec): + """An asset-package string: the common authored :class:`LangStrSpec` form. - ``subs`` maps each substitution keyword to its value -- a flat ``str`` / - ``int`` or a nested :class:`Lstr`. A no-arg string has empty ``subs``. - The value carries its own exact ``apverid`` (so an encode context can - discover the package union from the values themselves) and the string's - logical ``name`` (mapped to its integer index at encode time). - - This is ``@ioprepped`` so it can be sent directly on the wire (the - name-based docui-v2 form). Flat ``str``/``int`` subs serialize directly; - nested-:class:`Lstr` subs are exercised by the in-memory encode / - name-decode paths but are not yet directly JSON-serializable here (they - graduate with the integer-indexed :data:`EncodedLstr` form). + ``subs`` maps each substitution keyword to its value -- a flat + ``str`` / ``int`` or a nested :class:`LangStrSpec`. A no-arg string has + empty ``subs``. The value carries its own exact ``apverid`` (so an + encode context can discover the package union from the values + themselves) and the string's logical ``name`` (mapped to its + integer index at encode time). """ apverid: Annotated[str, IOAttrs('a')] name: Annotated[str, IOAttrs('n')] - subs: Annotated[dict, IOAttrs('s', store_default=False)] = field( - default_factory=dict - ) + subs: Annotated[ + dict[str, str | int | LangStrSpec], + IOAttrs('s', store_default=False), + ] = field(default_factory=dict) + @override + @classmethod + def get_type_id(cls) -> LangStrSpecTypeID: + return LangStrSpecTypeID.RESOURCE + + +@ioprepped +@dataclass +class LangStrSpecValue(LangStrSpec): + """A raw literal string value needing no asset package. + + For server-generated dynamic text (player names, pre-formatted + numbers, etc.) that rides a :class:`LangStrSpec`-shaped slot without a + package entry. The value is locale-independent; ``subs`` are + substituted into ``{name}`` tokens exactly like a plain resource + value (nested language-strings allowed). + """ + + value: Annotated[str, IOAttrs('v')] + subs: Annotated[ + dict[str, str | int | LangStrSpec], + IOAttrs('s', store_default=False), + ] = field(default_factory=dict) + + @override + @classmethod + def get_type_id(cls) -> LangStrSpecTypeID: + return LangStrSpecTypeID.VALUE + + +@ioprepped +@dataclass +class LangStrSpecResourceIndexed(LangStrSpec): + """The compact integer-addressed projection of a resource string. + + Usable only against a context carrying the ``{pkg_int: apverid}`` + package-index map (and package structures for positional-sub + ordering); see ``LanguageStringDecodeContext``. Substitutions are + positional here (canonical param order), matching the + :data:`EncodedLangStr` chunk model. This form is the multitype + default, so it serializes without a type tag. + """ + + pkg: Annotated[int, IOAttrs('p')] + index: Annotated[int, IOAttrs('n')] + subs: Annotated[ + list[str | int | LangStrSpec], + IOAttrs('s', store_default=False), + ] = field(default_factory=list) + + @override + @classmethod + def get_type_id(cls) -> LangStrSpecTypeID: + return LangStrSpecTypeID.RESOURCE_INDEXED + + +#: First engine build with full current language-string support: +#: nested-:class:`LangStrSpec` substitutions, the type-tagged wire form, +#: and the value/indexed forms. Older builds understand only tag-free +#: resource values with flat subs (they tolerate an unrecognized type +#: tag on those); servers that know the client build must gate +#: everything beyond that on this floor. +LANGSTR_EXT_MIN_BUILD = 22933 #: A substitution value: a flat string/number, or a nested language-string. -type LstrSub = str | int | Lstr +type LangStrSub = str | int | LangStrSpec #: An encoded chunk: ``[pkg_int, str_int, sub0, sub1, …]`` where each sub is #: a flat ``str``/``int`` or a nested chunk (a list). Plain JSON -- the #: flat-vs-nested distinction is "str/int vs list". -type EncodedLstr = list[str | int | 'EncodedLstr'] +type EncodedLangStr = list[str | int | 'EncodedLangStr'] @dataclass(frozen=True) @@ -84,13 +258,19 @@ class StringDef: """One string's language-free definition. ``params`` is the ordered list of ``(keyword, kind)`` where kind is - ``'text'`` (a text sub -> ``str | Lstr``) or ``'count'`` (the plural + ``'text'`` (a text sub -> ``str | LangStrSpec``) or ``'count'`` (the plural pivot -> ``int``); ``()`` for a no-arg string. The canonical ordering (sorted keyword) is what fixes the positional substitution order. + + ``docs`` (author usage docs) and ``english`` (an English preview of + the rendered text) are optional docstring material for wrapper + codegen only -- neither participates in the encode/decode structure. """ path: str params: tuple[tuple[str, str], ...] = () + docs: str = '' + english: str = '' @dataclass(frozen=True) @@ -126,17 +306,48 @@ class PackageStructure: }, ) + @classmethod + def from_language_values( + cls, apverid: str, values: dict[str, str | StringSelector] + ) -> 'PackageStructure': + """Derive the structure from one locale's complete value set. + + The consumer-side counterpart of :meth:`from_def`: string + indices come from the canonical sorted-name order (the key set + is identical across locales by construction -- see + ``complete_locale_values``) and each string's substitution + keywords from its own value via + :func:`bacommon.loctext.substitution_names`. Both ends + canonicalize param order alphabetically, so a structure derived + here agrees with the producer's brief-derived one; producer + tests lock that agreement. + """ + from bacommon.loctext import substitution_names + + return cls( + apverid, + { + name: tuple(substitution_names(value)) + for name, value in values.items() + }, + ) + def __init__( self, apverid: str, strings: dict[str, tuple[str, ...]] ) -> None: - #: ``strings`` maps each logical name to its ordered substitution - #: keywords (``()`` for a no-arg string). + #: ``strings`` maps each logical name to its substitution + #: keywords (``()`` for a no-arg string). Order of the passed + #: keywords is ignored: positional-substitution order is + #: canonically alphabetical, enforced here so producer- and + #: consumer-derived structures can't disagree on it. self.apverid = apverid self._names: tuple[str, ...] = tuple(sorted(strings)) self._index: dict[str, int] = { name: i for i, name in enumerate(self._names) } - self._params: dict[str, tuple[str, ...]] = dict(strings) + self._params: dict[str, tuple[str, ...]] = { + name: tuple(sorted(params)) for name, params in strings.items() + } def index_of(self, name: str) -> int: """Return the integer index for a string name.""" @@ -152,7 +363,7 @@ class PackageStructure: class LanguageStringEncodeContext: - """Encodes :class:`Lstr` values into minimal language-free chunks. + """Encodes :class:`LangStrSpec` values into minimal language-free chunks. Built from the batch of values to send: it computes the union of apverids they reference (recursively -- nested values know their own @@ -164,7 +375,7 @@ class LanguageStringEncodeContext: def __init__( self, - lstrs: list[Lstr], + lstrs: list[LangStrSpec], structures: dict[str, PackageStructure], ) -> None: self._structures = structures @@ -174,10 +385,19 @@ class LanguageStringEncodeContext: # Sorted -> deterministic indices for a given apverid set. self._pkg_index = {av: i for i, av in enumerate(sorted(apverids))} - def _collect(self, lstr: Lstr, acc: set[str]) -> None: - acc.add(lstr.apverid) - for val in lstr.subs.values(): - if isinstance(val, Lstr): + def _collect(self, lstr: LangStrSpec, acc: set[str]) -> None: + if isinstance(lstr, LangStrSpecResource): + acc.add(lstr.apverid) + subvals = list(lstr.subs.values()) + elif isinstance(lstr, LangStrSpecValue): + # Literals reference no package but may nest values that do. + subvals = list(lstr.subs.values()) + else: + raise LangStrError( + f'cannot encode an already-indexed' f' {type(lstr).__name__}.' + ) + for val in subvals: + if isinstance(val, LangStrSpec): self._collect(val, acc) @property @@ -185,8 +405,13 @@ class LanguageStringEncodeContext: """The ``{pkg_int: apverid}`` map a decoder needs.""" return {i: av for av, i in self._pkg_index.items()} - def encode(self, lstr: Lstr) -> EncodedLstr: + def encode(self, lstr: LangStrSpec) -> EncodedLangStr: """Encode one value (recursively) into a minimal chunk.""" + if not isinstance(lstr, LangStrSpecResource): + raise LangStrError( + f'only resource-form language-strings can be encoded;' + f' got {type(lstr).__name__}.' + ) pkg_int = self._pkg_index.get(lstr.apverid) struct = self._structures.get(lstr.apverid) if pkg_int is None or struct is None: @@ -200,16 +425,68 @@ class LanguageStringEncodeContext: raise LangStrError( f'unknown string {lstr.name!r} in {lstr.apverid}' ) from exc - out: list[str | int | EncodedLstr] = [pkg_int, str_int] + out: list[str | int | EncodedLangStr] = [pkg_int, str_int] for param in params: if param not in lstr.subs: raise LangStrError( f'missing substitution {param!r} for {lstr.name!r}' ) val = lstr.subs[param] - out.append(self.encode(val) if isinstance(val, Lstr) else val) + out.append( + self.encode(val) if isinstance(val, LangStrSpec) else val + ) return out + def to_indexed(self, lstr: LangStrSpec) -> LangStrSpec: + """Convert a resource/value tree to its integer-indexed form. + + Resource nodes become :class:`LangStrSpecResourceIndexed` (with + positional subs in canonical param order); literal + :class:`LangStrSpecValue` nodes pass through (with their nested + subs converted). New objects are returned; the input tree is + never mutated. Raises :class:`LangStrError` loudly for + packages/strings unknown to this context (authoring-side + errors) or already-indexed input. + """ + if isinstance(lstr, LangStrSpecValue): + return LangStrSpecValue( + lstr.value, + { + key: ( + self.to_indexed(val) + if isinstance(val, LangStrSpec) + else val + ) + for key, val in lstr.subs.items() + }, + ) + if not isinstance(lstr, LangStrSpecResource): + raise LangStrError(f'cannot index a {type(lstr).__name__}.') + pkg_int = self._pkg_index.get(lstr.apverid) + struct = self._structures.get(lstr.apverid) + if pkg_int is None or struct is None: + raise LangStrError( + f'apverid {lstr.apverid!r} is not in this encode context' + ) + try: + str_int = struct.index_of(lstr.name) + params = struct.params_of(lstr.name) + except KeyError as exc: + raise LangStrError( + f'unknown string {lstr.name!r} in {lstr.apverid}' + ) from exc + subs: list[str | int | LangStrSpec] = [] + for param in params: + if param not in lstr.subs: + raise LangStrError( + f'missing substitution {param!r} for {lstr.name!r}' + ) + val = lstr.subs[param] + subs.append( + self.to_indexed(val) if isinstance(val, LangStrSpec) else val + ) + return LangStrSpecResourceIndexed(pkg=pkg_int, index=str_int, subs=subs) + class LanguageStringDecodeContext: """Decodes chunks into flat strings for one target locale. @@ -233,19 +510,21 @@ class LanguageStringDecodeContext: self._language = language self._locale = locale - def decode(self, encoded: EncodedLstr) -> str: + def decode(self, encoded: EncodedLangStr) -> str: """Resolve a chunk to a flat string in this context's locale. - Fail-visible: any structural problem yields an ``LSTR_ERROR:…`` + Fail-visible: any structural problem yields an ``LANGSTR_ERROR:…`` sentinel (and a logged warning) rather than crashing the caller. """ try: return self._decode(encoded) except _DecodeFail as exc: logger.warning('langstr decode: %s', exc) - return f'LSTR_ERROR:{exc}' + return f'LANGSTR_ERROR:{exc}' - def _decode(self, encoded: EncodedLstr) -> str: + def _decode(self, encoded: EncodedLangStr, depth: int = 0) -> str: + if depth > MAX_NESTING_DEPTH: + raise _DecodeFail('max nesting depth exceeded') if len(encoded) < 2: raise _DecodeFail(f'malformed chunk {encoded!r}') pkg_int = encoded[0] @@ -263,7 +542,7 @@ class LanguageStringDecodeContext: try: name = struct.name_of(str_int) params = struct.params_of(name) - except (IndexError, KeyError): + except IndexError, KeyError: raise _DecodeFail( f'unknown string index {str_int} in {apverid}' ) from None @@ -278,27 +557,234 @@ class LanguageStringDecodeContext: kwargs: dict[str, str | int] = {} for param, sub in zip(params, subs): # A nested chunk (list) renders recursively to a flat string. - kwargs[param] = self.decode(sub) if isinstance(sub, list) else sub + kwargs[param] = ( + self._decode(sub, depth + 1) if isinstance(sub, list) else sub + ) try: return evaluate(values[name], self._locale, **kwargs) except LocTextError as exc: raise _DecodeFail(f'eval failed for {name!r}: {exc}') from exc + def to_resource(self, lstr: LangStrSpec, _depth: int = 0) -> LangStrSpec: + """Convert integer-indexed nodes back to the resource form. + + The inverse of ``LanguageStringEncodeContext.to_indexed``, for + consumers that ingest indexed wire values but hold some of them + in the self-describing name form (e.g. deferred client effects + that outlive their containing payload's package-index map). + Returns new objects (resource/value nodes are rebuilt with + converted subs); raises :class:`LangStrError` for indices + unknown to this context. + """ + if _depth > MAX_NESTING_DEPTH: + raise LangStrError('max nesting depth exceeded') + if isinstance(lstr, LangStrSpecValue): + return LangStrSpecValue( + lstr.value, + { + key: ( + self.to_resource(val, _depth + 1) + if isinstance(val, LangStrSpec) + else val + ) + for key, val in lstr.subs.items() + }, + ) + if isinstance(lstr, LangStrSpecResource): + return LangStrSpecResource( + lstr.apverid, + lstr.name, + { + key: ( + self.to_resource(val, _depth + 1) + if isinstance(val, LangStrSpec) + else val + ) + for key, val in lstr.subs.items() + }, + ) + if not isinstance(lstr, LangStrSpecResourceIndexed): + raise LangStrError(f'cannot convert a {type(lstr).__name__}.') + apverid = self._pkg_map.get(lstr.pkg) + if apverid is None or apverid not in self._structures: + raise LangStrError(f'unknown package index {lstr.pkg}') + struct = self._structures[apverid] + try: + name = struct.name_of(lstr.index) + params = struct.params_of(name) + except (IndexError, KeyError) as exc: + raise LangStrError( + f'unknown string index {lstr.index} in {apverid}' + ) from exc + if len(lstr.subs) != len(params): + raise LangStrError(f'arity mismatch for {name!r}') + return LangStrSpecResource( + apverid, + name, + { + param: ( + self.to_resource(sub, _depth + 1) + if isinstance(sub, LangStrSpec) + else sub + ) + for param, sub in zip(params, lstr.subs) + }, + ) + + def decode_value(self, lstr: LangStrSpec) -> str: + """Resolve any language-string form to flat text in this locale. + + The tolerant all-forms counterpart of :meth:`decode`: handles + :class:`LangStrSpecResourceIndexed` (via this context's + package-index map + structures), :class:`LangStrSpecValue` + (self-contained), and plain :class:`LangStrSpecResource` (by name, + for legacy/mixed payloads). Fail-visible like everything else + on the decode side. + """ + try: + return self._decode_value(lstr, 0) + except _DecodeFail as exc: + logger.warning('langstr decode: %s', exc) + return f'LANGSTR_ERROR:{exc}' + + def _decode_value(self, lstr: LangStrSpec, depth: int) -> str: + if depth > MAX_NESTING_DEPTH: + raise _DecodeFail('max nesting depth exceeded') + value: str | StringSelector + desc: str + kwargs: dict[str, str | int] = {} + if isinstance(lstr, LangStrSpecResourceIndexed): + apverid = self._pkg_map.get(lstr.pkg) + if ( + apverid is None + or apverid not in self._structures + or apverid not in self._language + ): + raise _DecodeFail(f'unknown package index {lstr.pkg}') + struct = self._structures[apverid] + try: + name = struct.name_of(lstr.index) + params = struct.params_of(name) + except IndexError, KeyError: + raise _DecodeFail( + f'unknown string index {lstr.index} in {apverid}' + ) from None + values = self._language[apverid] + if name not in values: + raise _DecodeFail(f'no value for {name!r} in {apverid}') + if len(lstr.subs) != len(params): + raise _DecodeFail( + f'arity mismatch for {name!r}:' + f' {len(lstr.subs)} != {len(params)}' + ) + for param, sub in zip(params, lstr.subs): + kwargs[param] = ( + self._decode_value(sub, depth + 1) + if isinstance(sub, LangStrSpec) + else sub + ) + value = values[name] + desc = name + elif isinstance(lstr, LangStrSpecValue): + value = lstr.value + desc = 'literal' + for key, sub in lstr.subs.items(): + kwargs[key] = ( + self._decode_value(sub, depth + 1) + if isinstance(sub, LangStrSpec) + else sub + ) + elif isinstance(lstr, LangStrSpecResource): + resvalues = self._language.get(lstr.apverid) + if resvalues is None: + raise _DecodeFail(f'no values for package {lstr.apverid!r}') + resval = resvalues.get(lstr.name) + if resval is None: + raise _DecodeFail( + f'no value for {lstr.name!r} in {lstr.apverid}' + ) + for key, sub in lstr.subs.items(): + kwargs[key] = ( + self._decode_value(sub, depth + 1) + if isinstance(sub, LangStrSpec) + else sub + ) + value = resval + desc = lstr.name + else: + raise _DecodeFail(f'cannot decode a {type(lstr).__name__}.') + try: + return evaluate(value, self._locale, **kwargs) + except LocTextError as exc: + raise _DecodeFail(f'eval failed for {desc!r}: {exc}') from exc + + +def contains_resource_form(lstr: LangStrSpec) -> bool: + """Return whether a language-string tree contains any full + resource-form (non-indexed) node. + + Used by consumers verifying that a wire payload claiming the + integer-indexed form really is fully indexed (a resource-form leak + means some producer path skipped indexing). + """ + subvals: list[str | int | LangStrSpec] + if isinstance(lstr, LangStrSpecResource): + return True + if isinstance(lstr, LangStrSpecValue): + subvals = list(lstr.subs.values()) + elif isinstance(lstr, LangStrSpecResourceIndexed): + subvals = list(lstr.subs) + else: + return False + return any( + isinstance(sub, LangStrSpec) and contains_resource_form(sub) + for sub in subvals + ) + + +def collect_apverids(lstr: LangStrSpec, acc: set[str]) -> None: + """Gather every asset-package-version a language-string tree + references into ``acc``. + + Indexed nodes resolve against an out-of-band context so they + contribute no apverids themselves, but their substitution values + are still walked (a resource-form node can appear anywhere in a + mixed tree). + + Note to implementers: keep this a module-level function; a + self-recursive closure would create a reference cycle (function -> + closure cell -> function) at every call site, adding cyclic-gc + pressure the engine works hard to avoid. + """ + subvals: list[str | int | LangStrSpec] + if isinstance(lstr, LangStrSpecResource): + acc.add(lstr.apverid) + subvals = list(lstr.subs.values()) + elif isinstance(lstr, LangStrSpecValue): + subvals = list(lstr.subs.values()) + elif isinstance(lstr, LangStrSpecResourceIndexed): + subvals = list(lstr.subs) + else: + return + for sub in subvals: + if isinstance(sub, LangStrSpec): + collect_apverids(sub, acc) + class LanguageStringNameDecodeContext: - """Decodes :class:`Lstr` values directly, by name, for one locale. + """Decodes :class:`LangStrSpec` values directly, by name, for one locale. The name-based counterpart to :class:`LanguageStringDecodeContext`: it - resolves an in-memory :class:`Lstr` (carrying its ``apverid``, string + resolves an in-memory :class:`LangStrSpec` (carrying its ``apverid``, string ``name``, and keyword ``subs``) straight against per-apverid per-locale values -- no integer indices, package-index-map, or :class:`PackageStructure` needed, since the subs are self-describing keyword->value pairs. This is the client's primary path: resolve the referenced packages, gather their per-locale values, then decode each - :class:`Lstr` in the client's locale. + :class:`LangStrSpec` in the client's locale. Fail-visible like :class:`LanguageStringDecodeContext` -- any structural - problem yields an ``LSTR_ERROR:…`` sentinel (and a logged warning) rather + problem yields an ``LANGSTR_ERROR:…`` sentinel (and a logged warning) rather than crashing the caller. """ @@ -311,30 +797,51 @@ class LanguageStringNameDecodeContext: self._language = language self._locale = locale - def decode(self, lstr: Lstr) -> str: - """Resolve an :class:`Lstr` to a flat string in this context's locale. + def decode(self, lstr: LangStrSpec) -> str: + """Resolve a :class:`LangStrSpec` to a flat string in this locale. - Fail-visible: any structural problem yields an ``LSTR_ERROR:…`` + Fail-visible: any structural problem yields an ``LANGSTR_ERROR:…`` sentinel (and a logged warning) rather than crashing the caller. """ try: return self._decode(lstr) except _DecodeFail as exc: logger.warning('langstr name-decode: %s', exc) - return f'LSTR_ERROR:{exc}' + return f'LANGSTR_ERROR:{exc}' - def _decode(self, lstr: Lstr) -> str: - values = self._language.get(lstr.apverid) - if values is None: - raise _DecodeFail(f'no values for package {lstr.apverid!r}') - value = values.get(lstr.name) - if value is None: - raise _DecodeFail(f'no value for {lstr.name!r} in {lstr.apverid}') + def _decode(self, lstr: LangStrSpec, depth: int = 0) -> str: + if depth > MAX_NESTING_DEPTH: + raise _DecodeFail('max nesting depth exceeded') + value: str | StringSelector + if isinstance(lstr, LangStrSpecValue): + # A raw literal; the value itself is the (locale-free) text. + value = lstr.value + subs = lstr.subs + desc = 'literal' + elif isinstance(lstr, LangStrSpecResource): + values = self._language.get(lstr.apverid) + if values is None: + raise _DecodeFail(f'no values for package {lstr.apverid!r}') + resval = values.get(lstr.name) + if resval is None: + raise _DecodeFail( + f'no value for {lstr.name!r} in {lstr.apverid}' + ) + value = resval + subs = lstr.subs + desc = lstr.name + else: + # The indexed form needs an index context, not this one. + raise _DecodeFail(f'cannot name-decode a {type(lstr).__name__}.') kwargs: dict[str, str | int] = {} - for key, sub in lstr.subs.items(): - # A nested Lstr renders recursively to a flat string. - kwargs[key] = self._decode(sub) if isinstance(sub, Lstr) else sub + for key, sub in subs.items(): + # A nested LangStrSpec renders recursively to a flat string. + kwargs[key] = ( + self._decode(sub, depth + 1) + if isinstance(sub, LangStrSpec) + else sub + ) try: return evaluate(value, self._locale, **kwargs) except LocTextError as exc: - raise _DecodeFail(f'eval failed for {lstr.name!r}: {exc}') from exc + raise _DecodeFail(f'eval failed for {desc!r}: {exc}') from exc diff --git a/dist/ba_data/python/bacommon/langstr/_wrapper.py b/dist/ba_data/python/bacommon/langstr/_wrapper.py index 90ea26e..c4dbcd9 100644 --- a/dist/ba_data/python/bacommon/langstr/_wrapper.py +++ b/dist/ba_data/python/bacommon/langstr/_wrapper.py @@ -6,12 +6,18 @@ A generated wrapper module exposes a ``strings`` object built from a compact nested param-tree; its precise types live in the module's ``if TYPE_CHECKING`` shadow (decision #28 -- bare annotations, no per-entry runtime class). This drives the *runtime* side: a no-arg string reads as a -property yielding an :class:`Lstr`, a parameterized one is a callable that -builds an :class:`Lstr` from keyword substitutions, and a subdir is a +property yielding a :class:`LangStrSpec`; a parameterized one is a +callable that +builds an :class:`LangStrSpec` from keyword substitutions, and a subdir is a nested :class:`LangStrDir`. """ -from bacommon.langstr._core import Lstr, PackageStructure +from typing import TYPE_CHECKING + +from bacommon.langstr._core import LangStrSpecResource, PackageStructure + +if TYPE_CHECKING: + from bacommon.langstr._core import LangStrSpec #: A wrapper's compact runtime tree: a leaf is its ordered param-keyword #: tuple (``()`` for a no-arg string); a subdir is a nested tree. @@ -26,21 +32,25 @@ def package_structure(apverid: str, tree: WrapperTree) -> PackageStructure: just passes ``module.APVERID, module._TREE`` (both module-level). """ flat: dict[str, tuple[str, ...]] = {} - - def _walk(node: WrapperTree, prefix: str) -> None: - for name, value in node.items(): - full = f'{prefix}/{name}' if prefix else name - if isinstance(value, dict): - _walk(value, full) - else: - flat[full] = value - - _walk(tree, '') + _flatten_tree(tree, '', flat) return PackageStructure(apverid, flat) +# (Module-level rather than a closure inside package_structure; a +# self-recursive closure creates a reference cycle per call.) +def _flatten_tree( + node: WrapperTree, prefix: str, flat: dict[str, tuple[str, ...]] +) -> None: + for name, value in node.items(): + full = f'{prefix}/{name}' if prefix else name + if isinstance(value, dict): + _flatten_tree(value, full, flat) + else: + flat[full] = value + + class _LstrMaker: - """Callable leaf: builds an :class:`Lstr` from keyword substitutions.""" + """Callable leaf: builds a :class:`LangStrSpec` from keyword subs.""" __slots__ = ('_apverid', '_name') @@ -48,8 +58,8 @@ class _LstrMaker: self._apverid = apverid self._name = name - def __call__(self, **subs: 'str | int | Lstr') -> Lstr: - return Lstr(self._apverid, self._name, dict(subs)) + def __call__(self, **subs: 'str | int | LangStrSpec') -> 'LangStrSpec': + return LangStrSpecResource(self._apverid, self._name, dict(subs)) class LangStrDir: @@ -64,7 +74,7 @@ class LangStrDir: self._tree = tree self._prefix = prefix - def __getattr__(self, name: str) -> 'Lstr | _LstrMaker | LangStrDir': + def __getattr__(self, name: str) -> 'LangStrSpec | _LstrMaker | LangStrDir': try: child = self._tree[name] except KeyError: @@ -73,7 +83,7 @@ class LangStrDir: if isinstance(child, dict): return LangStrDir(self._apverid, child, full) # A leaf: its param-keyword tuple. Empty -> a no-arg string, read - # as a property yielding the Lstr directly; otherwise a maker. + # as a property yielding the LangStrSpec directly; otherwise a maker. if not child: - return Lstr(self._apverid, full) + return LangStrSpecResource(self._apverid, full) return _LstrMaker(self._apverid, full) diff --git a/dist/ba_data/python/bacommon/loctext.py b/dist/ba_data/python/bacommon/loctext.py index 8245a6f..fdc1ae8 100644 --- a/dist/ba_data/python/bacommon/loctext.py +++ b/dist/ba_data/python/bacommon/loctext.py @@ -323,6 +323,25 @@ class StringSelector: forms: Annotated[dict[str, str], IOAttrs('f')] +def substitution_names(value: str | StringSelector) -> set[str]: + """Return the substitution-argument names a value consumes. + + For a plain string these are its ``{name}`` tokens; for a + :class:`StringSelector` its pivot ``arg`` plus any ``{name}`` + tokens in its forms. This is the single source consumers use to + derive a string's parameter set from its value (e.g. rebuilding + positional-substitution order client-side) -- the producer-side + round-trip validation guarantees every parameter's token survives + into every stored output, so the derivation is total. + """ + if isinstance(value, StringSelector): + names = {value.arg} + for form in value.forms.values(): + names.update(_SUB_RE.findall(form)) + return names + return set(_SUB_RE.findall(value)) + + def evaluate( value: 'str | StringSelector', locale: Locale, **args: object ) -> str: diff --git a/dist/ba_data/python/bacommon/servermanager.py b/dist/ba_data/python/bacommon/servermanager.py index 00d8348..1da4087 100644 --- a/dist/ba_data/python/bacommon/servermanager.py +++ b/dist/ba_data/python/bacommon/servermanager.py @@ -25,6 +25,10 @@ class ServerConfig: # address. party_is_public: bool = True + # If set, clients must provide this password before joining your + # party. + password: str | None = None + # If True, the master-server will provide your server with verified # account info for all connecting clients. Generally this should # always be enabled unless you are hosting on a LAN with no internet diff --git a/dist/ba_data/python/bacommon/workspace/assetsv1.py b/dist/ba_data/python/bacommon/workspace/assetsv1.py index 32fefc5..690658e 100644 --- a/dist/ba_data/python/bacommon/workspace/assetsv1.py +++ b/dist/ba_data/python/bacommon/workspace/assetsv1.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Annotated, override, assert_never from efro.dataclassio import ioprepped, IOAttrs, IOMultiType from bacommon.locale import Locale +from bacommon.langstr import WrapParams from bacommon.loctext import StringSelector if TYPE_CHECKING: @@ -34,11 +35,18 @@ class WrapperType(Enum): BASCENEV1 = 'bascenev1' BAUIV1 = 'bauiv1' - #: Strings-only wrapper (asset-packages strings phase). Strings - #: resolve via ``_babase.get_resource`` (a base-level concept, not a - #: scene/UI loader), so they live in their own babase-rooted wrapper - #: whose leaves are call-time-resolved ``str`` accessors. - BABASE = 'babase' + + +class ConventionsMode(Enum): + """Conventions-check enforcement level for an assets_v1 workspace. + + ``STRICT`` blocks test/prod publishes while conventions findings + exist (dev-track resolves are never gated); ``RELAXED`` (the + default) surfaces findings as informational hints only. + """ + + RELAXED = 'relaxed' + STRICT = 'strict' @ioprepped @@ -59,6 +67,52 @@ class AssetsV1GlobalVals: #: summary line). Empty string means none. docs: Annotated[str, IOAttrs('docs', store_default=False)] = '' + #: Dev-team id granting resolve access to this workspace's + #: dev/test asset-package versions. None (unset) means owner-only + #: access — matching the semantics of the asset-package doc's + #: ``dev_team_id`` (see ``AssetPackage.account_has_access``). + dev_team: Annotated[ + str | None, IOAttrs('dev_team', store_default=False) + ] = None + + #: The asset-package name this workspace publishes under. None + #: means it is derived from the workspace's display name (see + #: :func:`derive_asset_package_name`); set explicitly to decouple + #: the published name from the display name (e.g. to keep a + #: package lineage across a workspace rename, or to have a new + #: workspace take over publishing an existing package name). + asset_package_name: Annotated[ + str | None, IOAttrs('asset_package_name', store_default=False) + ] = None + + #: Conventions-check enforcement level (see + #: :class:`ConventionsMode`). First-party workspaces set strict + #: (see the asset-packages design doc). Set by hand in + #: ``workspace.json`` -- deliberately not exposed in the UI. + #: Unknown stored values fall back to relaxed so older servers + #: never over-enforce. + conventions: Annotated[ + ConventionsMode, + IOAttrs( + 'conventions', + store_default=False, + enum_fallback=ConventionsMode.RELAXED, + ), + ] = ConventionsMode.RELAXED + + +def derive_asset_package_name(workspace_name: str) -> str: + """Derive a default asset-package name from a workspace name. + + Lowercases and strips spaces ('My Awesome Assets' -> + 'myawesomeassets'). The single source for this rule — publish + paths, collision checks, and UI previews must all route through + it. Note the result is not guaranteed to be a *valid* + asset-package name (the workspace name may contain characters + with no valid mapping); consumers validate at point of use. + """ + return workspace_name.lower().replace(' ', '') + class AssetsV1StringFileTypeID(Enum): """Type ID for each of our subclasses.""" @@ -111,6 +165,42 @@ class AssetsV1StringFileV1(AssetsV1StringFile): LOUD = 'loud' SOFT = 'soft' + class FitPreset(Enum): + """Preset bounding translated-output size for UI space. + + Mirrors ``StylePreset``: a rough size budget passed to + the translator (with UI context) so localized output respects + the space available. Budgets are display-width in *Latin* + characters -- wide-glyph scripts (CJK) target roughly half the + character count -- and are aims, not hard caps (soft + enforcement with generous slack; see ``char_budget``). + """ + + #: No size constraint (the default). + NONE = 'none' + + #: Aim for ~20 characters - narrow buttons, tabs, column + #: headings. + CHARS_20 = 'chars_20' + + #: Aim for ~40 characters - standard buttons and labels. + CHARS_40 = 'chars_40' + + #: Aim for ~80 characters / one concise line - transient + #: messages, status lines, and the like. + CHARS_80 = 'chars_80' + + @property + def char_budget(self) -> int | None: + """The preset's rough character budget (None for NONE).""" + cls = type(self) + return { + cls.NONE: None, + cls.CHARS_20: 20, + cls.CHARS_40: 40, + cls.CHARS_80: 80, + }[self] + @override @classmethod def get_type_id(cls) -> AssetsV1StringFileTypeID: @@ -125,16 +215,13 @@ class AssetsV1StringFileV1(AssetsV1StringFile): datetime.datetime, IOAttrs('modtime', time_format='float') ] - #: The plain-string output. Set for plain entries; empty (and - #: omitted from the wire) when ``selector`` is set -- the selector - #: is then the authoritative value, with no separate fallback. - value: Annotated[str, IOAttrs('value', store_default=False)] = '' - - #: Optional render-time selector (plural/select); set instead of - #: ``value`` for an entry whose value is chosen at render time. - selector: Annotated[ - StringSelector | None, IOAttrs('sel', store_default=False) - ] = None + #: The localized output -- a plain string, or a render-time + #: :class:`~bacommon.loctext.StringSelector` (plural/select) + #: whose final form is chosen at display time. (A type-disjoint + #: dataclassio union; selectors ride the wire as dicts.) + value: Annotated[ + str | StringSelector, IOAttrs('value', store_default=False) + ] = '' input: Annotated[str, IOAttrs('input')] input_modtime: Annotated[ @@ -143,21 +230,112 @@ class AssetsV1StringFileV1(AssetsV1StringFile): style_preset: Annotated[ StylePreset, IOAttrs('style_preset', store_default=False) ] = StylePreset.NONE + + #: Optional free-form usage docs describing where this string + #: appears and how it is used. Feeds both the generated wrapper + #: accessor's docstring + #: and the translation prompt (as usage context). Lives in the + #: ``.bstr`` itself so edits restale translations via the file's + #: content-id; when an edit doesn't warrant regeneration, use the + #: UI's mark-translations-clean action. + docs: Annotated[str, IOAttrs('docs', store_default=False)] = '' + + #: Optional size/fit constraint (see ``FitPreset``). Passed to + #: the translator so localized output respects the UI space + #: available. + fit_preset: Annotated[ + FitPreset, IOAttrs('fit_preset', store_default=False) + ] = FitPreset.NONE + outputs: Annotated[dict[Locale, Output], IOAttrs('outputs')] = field( default_factory=dict ) +class AssetsV1AprefFileTypeID(Enum): + """Type ID for each of our subclasses.""" + + V1 = 'v1' + + +class AssetsV1AprefFile(IOMultiType[AssetsV1AprefFileTypeID]): + """Top level class for our multitype. + + An ``.apref`` source in an assets_v1 workspace is an + asset-package reference: a pin to a published asset-package + version. String briefs can then reference the pinned package's + translations via cross-package term refs + (``{@:}``). + """ + + @override + @classmethod + def get_type_id_storage_name(cls) -> str: + return 'apref_file_version' + + @override + @classmethod + def get_type_id(cls) -> AssetsV1AprefFileTypeID: + # 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: AssetsV1AprefFileTypeID + ) -> type[AssetsV1AprefFile]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = AssetsV1AprefFileTypeID + if type_id is t.V1: + return AssetsV1AprefFileV1 + + # Important to make sure we provide all types. + assert_never(type_id) + + +@ioprepped +@dataclass +class AssetsV1AprefFileV1(AssetsV1AprefFile): + """Our initial version of asset-package-ref file data.""" + + @override + @classmethod + def get_type_id(cls) -> AssetsV1AprefFileTypeID: + return AssetsV1AprefFileTypeID.V1 + + #: The pinned asset-package-version id + #: (``..``). Always a concrete + #: version — including on the dev track (a specific ``devN`` + #: segment, never the bare ``dev`` pseudo-id); pins only move via + #: the explicit update/switch-track actions in the workspace UI. + apverid: Annotated[str, IOAttrs('apverid')] + + +#: Placeholder value for a string with no generated output in its own locale +#: *or* in English. We deliberately do NOT fall back to the brief ``input`` +#: here: that's the author's description of what the string should say (a +#: translator prompt), often a long-winded sentence -- not display text -- so +#: rendering it is worse than an obvious "untranslated" marker. +STRING_NOT_TRANSLATED = '' + + def complete_locale_values( string_files: dict[str, AssetsV1StringFileV1], locale: Locale ) -> dict[str, str | StringSelector]: """English-completed per-locale values for a set of string files. Maps each string's logical name to its value for ``locale``: the - locale's own output, else the English output, else the raw English brief - ``input``. So every locale's map carries the **complete key set** with - graceful English fallback -- untranslated strings still render (in - English) rather than failing, and every locale's key set is identical. + locale's own output, else the English output, else the + ``STRING_NOT_TRANSLATED`` placeholder. So every locale's map carries the + **complete key set** with graceful English fallback -- an untranslated + string still renders (in English where available, else an obvious + ```` marker) rather than failing, and every locale's key + set is identical. The brief ``input`` is intentionally never used as a + value: it's the author's prompt/description, not display text. The shared value-selection both the asset-build string recipe and the `langstr vendor` command route through (paired with @@ -169,12 +347,7 @@ def complete_locale_values( output = sfile.outputs.get(locale) if output is None: output = sfile.outputs.get(Locale.ENGLISH) - if output is None: - out[name] = sfile.input - elif output.selector is not None: - out[name] = output.selector - else: - out[name] = output.value + out[name] = STRING_NOT_TRANSLATED if output is None else output.value return out @@ -414,6 +587,18 @@ class AssetsV1PathValsTexV1(AssetsV1PathVals): #: Sphinx docs). Empty string means no docs. docs: Annotated[str, IOAttrs('docs', store_default=False)] = '' + #: Halve the fallback flavor's level0 downsize divisor (2 instead + #: of 4) so this asset's fallback carries a higher-res top mip. For + #: the rare asset whose fallback bytes get consumed directly rather + #: than just serving as a universal render fallback -- e.g. the + #: engine cursor texture feeding OS hardware cursors, which wants a + #: retina-res mip. Deliberately not exposed in the workspace web UI + #: (it would be noise there); edit workspace.json directly for the + #: odd asset that needs it. + fallback_high_res: Annotated[ + bool, IOAttrs('fallback_high_res', store_default=False) + ] = False + @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: @@ -456,6 +641,36 @@ class AssetsV1PathValsTexV1(AssetsV1PathVals): self.bc7_settings = bc7_defaults +@ioprepped +@dataclass +class AssetsV1StrTermDeps: + """Cached term-ref info for a ``.bstr``, keyed to its content. + + Term refs (``{@term}`` / ``{@pkg:term}`` in the brief) are a pure + function of the ``.bstr`` file's content, which is pinned by its + content-addressed ``file_id`` -- so this record stays valid exactly + as long as ``file_id`` matches the entry's current file. Consumers + (dep-aware staleness calcs) use it to skip reading the file; on + mismatch they fall back to reading that one file. Maintained + automatically by the string save/translate paths; do not hand-edit + (a wrong ``local``/``cross`` list with a matching ``file_id`` would + be trusted). + """ + + #: Content-id of the ``.bstr`` file these refs were extracted from. + file_id: Annotated[str, IOAttrs('file_id')] + + #: Local term-ref targets (logical ``.bstr`` paths, no extension). + local: Annotated[list[str], IOAttrs('local', store_default=False)] = field( + default_factory=list + ) + + #: Cross-package ref targets (``.apref`` paths, no extension). + cross: Annotated[list[str], IOAttrs('cross', store_default=False)] = field( + default_factory=list + ) + + @ioprepped @dataclass class AssetsV1PathValsStrV1(AssetsV1PathVals): @@ -463,10 +678,38 @@ class AssetsV1PathValsStrV1(AssetsV1PathVals): #: Hash generated when all translations for this entry are complete. #: Used as a fast-out for checking whether updates are needed. + #: + #: (Historical note: string author docs briefly lived here as a + #: ``docs`` path-val to avoid restaling translations; they moved + #: into the ``.bstr`` itself once docs began feeding the translation + #: prompt, with the UI's mark-translations-clean action as the + #: no-regeneration-needed escape hatch.) up_to_date_state: Annotated[ str | None, IOAttrs('up_to_date_state', store_default=False) ] = None + #: Optional definition-time line-wrapping hints (decision D-t in + #: the language-string-context initiative): applied automatically + #: at evaluation everywhere this string displays. Locale-invariant. + #: Lives HERE (not in the ``.bstr``) deliberately: the ``.bstr`` is + #: by definition the translation input, so its content hash is the + #: translation-staleness key, and display-side metadata like this + #: must not restale translations (the same reasoning as the docs + #: history above, in reverse -- wrap does not feed the translation + #: prompt). + wrap: Annotated[WrapParams | None, IOAttrs('wrap', store_default=False)] = ( + None + ) + + #: Cached term-ref info (see :class:`AssetsV1StrTermDeps`). Absent + #: until first extracted; ignored (and lazily recomputed from the + #: file) whenever its ``file_id`` no longer matches the entry's + #: current content -- so write paths that don't maintain it merely + #: cost a read, never a wrong answer. + deps: Annotated[ + AssetsV1StrTermDeps | None, IOAttrs('deps', store_default=False) + ] = None + @override @classmethod def get_type_id(cls) -> AssetsV1PathValsTypeID: diff --git a/dist/ba_data/python/bacommontools/streamws.py b/dist/ba_data/python/bacommontools/streamws.py index 8faa0b5..792ce62 100644 --- a/dist/ba_data/python/bacommontools/streamws.py +++ b/dist/ba_data/python/bacommontools/streamws.py @@ -15,8 +15,12 @@ token) we reconnect — refreshing the token via ``POST the token is expired (4001). Reconnects use exponential backoff up to a configurable wall-clock budget (default 60s, override via ``BACLOUD_RECONNECT_BUDGET_SECONDS``); past the budget we surface -``CleanError``. Token-bad / call-id-mismatch / no-token closes -(4002/4003/4004) are fatal — no retry. +``CleanError``. The budget bounds reconnect *churn*, not total +stream lifetime: it restarts whenever a healthy connection (one +that delivered at least one frame) drops, so long-lived streams +keep full reconnect protection, while repeated fruitless reconnect +attempts stay bounded. Token-bad / call-id-mismatch / no-token +closes (4002/4003/4004) are fatal — no retry. v0 reconnect doesn't ask basn to replay the cursor: a reconnecting client may miss frames that landed during the disconnect window. In @@ -197,6 +201,15 @@ async def _consume_with_reconnect( except _FatalAuth as exc: raise CleanError(f'Stream WS auth failed: {exc}') from exc except _Reconnectable as exc: + if exc.made_progress: + # The connection that dropped was healthy (frames + # flowed). The budget bounds reconnect *churn*, not + # total stream lifetime — a stream outliving it would + # otherwise get zero reconnect attempts on its first + # drop — so start it fresh. Failed reconnect attempts + # (no frames) do NOT reset it, keeping thrash bounded. + deadline = time.monotonic() + _reconnect_budget_seconds() + backoff = _RECONNECT_BACKOFF_MIN if time.monotonic() >= deadline: raise CleanError( f'Stream WS reconnect budget exhausted: {exc}' @@ -237,6 +250,7 @@ async def _consume_once( headers.append(('User-Agent', f'bacloud/{BACLOUD_VERSION}')) drop_task: asyncio.Task[None] | None = None + got_frame = False try: async with websockets.connect( # type: ignore[attr-defined] @@ -250,6 +264,7 @@ async def _consume_once( if isinstance(raw, bytes): raw = raw.decode('utf-8') frame = dataclass_from_json(StreamFrame, raw) + got_frame = True if isinstance(frame, StreamOutput): print(frame.text, end='', flush=True) elif isinstance(frame, StreamFinal): @@ -258,7 +273,9 @@ async def _consume_once( # reconnectable; basn's subscription is still # alive server-side (or has cleanly ended without # us seeing the terminal frame). - raise _Reconnectable('WS closed without terminal frame') + raise _Reconnectable( + 'WS closed without terminal frame', made_progress=got_frame + ) except InvalidStatus as exc: # Handshake-time HTTP error — basn rejected the upgrade # before we got an app-level close code. Treat as fatal: @@ -270,12 +287,17 @@ async def _consume_once( if exc.code in (4002, 4003, 4004): # token bad / mismatch / missing raise _FatalAuth(f'code={exc.code} reason={exc.reason!r}') from exc raise _Reconnectable( - f'closed: code={exc.code} reason={exc.reason!r}' + f'closed: code={exc.code} reason={exc.reason!r}', + made_progress=got_frame, ) from exc except WebSocketException as exc: - raise _Reconnectable(f'protocol error: {exc}') from exc + raise _Reconnectable( + f'protocol error: {exc}', made_progress=got_frame + ) from exc except OSError as exc: - raise _Reconnectable(f'connect failed: {exc}') from exc + raise _Reconnectable( + f'connect failed: {exc}', made_progress=got_frame + ) from exc finally: if drop_task is not None: drop_task.cancel() @@ -358,7 +380,16 @@ def _http_post(req: urllib.request.Request) -> bytes: class _Reconnectable(Exception): - """Internal: WS dropped on a recoverable signal; retry with backoff.""" + """Internal: WS dropped on a recoverable signal; retry with backoff. + + ``made_progress`` is True if at least one stream frame arrived on + the connection that dropped — i.e. the drop ended a *healthy* + connection rather than a failed reconnect attempt. + """ + + def __init__(self, msg: str, *, made_progress: bool = False) -> None: + super().__init__(msg) + self.made_progress = made_progress class _NeedsTokenRefresh(Exception): diff --git a/dist/ba_data/python/baenv.py b/dist/ba_data/python/baenv.py index 5d70578..ec78364 100644 --- a/dist/ba_data/python/baenv.py +++ b/dist/ba_data/python/baenv.py @@ -55,8 +55,8 @@ logger = logging.getLogger('ba.env') # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22919 -TARGET_BALLISTICA_VERSION = '1.8.0a32' +TARGET_BALLISTICA_BUILD = 22938 +TARGET_BALLISTICA_VERSION = '1.8.0a49' @dataclass @@ -318,16 +318,20 @@ def configure( def _cache_ninja_rampage(cache_dir: str) -> None: assert os.path.isdir(cache_dir) + + # Base rate is one kill per 4000 files, but occasionally go on + # bigger rampages so we exercise multi-file-loss cases too, not just + # single stragglers: 60% of runs at base rate, 30% at 5x, 10% at + # 10x. Can recalibrate as our average cache file count goes up. + kill_probability = ( + 0.0001 * random.choices((1, 5, 10), weights=(60, 30, 10))[0] + ) + + kill_count = 0 for basename, _dirnames, filenames in os.walk(cache_dir): for fname in filenames: - # Let's kill one out of every 1000 files; should be a - # reasonable amount of chaos I think. Can recalibrate this - # as our average cache file count goes up. - if random.random() < 0.001: + if random.random() < kill_probability: fullpath = os.path.join(basename, fname) - logging.getLogger('ba.cache').debug( - "Cache-ninja assassinated '%s'.", fullpath - ) # The whole point of this feature is that downstream # code must handle missing cache files; the kill itself # is not load-bearing. Swallow OSError so a read-only @@ -336,7 +340,17 @@ def _cache_ninja_rampage(cache_dir: str) -> None: try: os.unlink(fullpath) except OSError: - pass + continue + kill_count += 1 + logging.getLogger('ba.cache').debug( + "Cache-ninja assassinated '%s'.", fullpath + ) + if kill_count: + logging.getLogger('ba.app').info( + 'Cache ninja assassinated %d %s! See ba.cache log for details.', + kill_count, + 'file' if kill_count == 1 else 'files', + ) def _read_app_config(config_file_path: str) -> dict: diff --git a/dist/ba_data/python/bascenev1/__init__.py b/dist/ba_data/python/bascenev1/__init__.py index aa4135f..1ff0326 100644 --- a/dist/ba_data/python/bascenev1/__init__.py +++ b/dist/ba_data/python/bascenev1/__init__.py @@ -79,7 +79,6 @@ from _bascenev1 import ( chatmessage, client_info_query_response, CollisionMesh, - connect_to_party, Data, disconnect_client, disconnect_from_host, @@ -138,6 +137,8 @@ from _bascenev1 import ( set_admins, set_authenticate_clients, set_debug_speed_exponent, + set_host_password, + set_hosting_asset_packages, set_enable_default_kick_voting, set_internal_music, set_map_bounds, @@ -213,7 +214,12 @@ from bascenev1._multiteamsession import ( DEFAULT_TEAM_NAMES, ) from bascenev1._music import MusicType, setmusic -from bascenev1._net import HostInfo +from bascenev1._net import ( + connect_to_party, + fetch_host_requirements, + HostInfo, + HostRequirements, +) from bascenev1._nodeactor import NodeActor from bascenev1._powerup import get_default_powerup_distribution from bascenev1._profile import ( @@ -358,7 +364,9 @@ __all__ = [ 'have_connected_clients', 'have_touchscreen_input', 'HitMessage', + 'fetch_host_requirements', 'HostInfo', + 'HostRequirements', 'host_scan_cycle', 'ImpactDamageMessage', 'increment_analytics_count', @@ -433,8 +441,9 @@ __all__ = [ 'set_analytics_screen', 'set_authenticate_clients', 'set_debug_speed_exponent', - 'set_debug_speed_exponent', 'set_enable_default_kick_voting', + 'set_host_password', + 'set_hosting_asset_packages', 'set_internal_music', 'set_map_bounds', 'set_master_server_source', diff --git a/dist/ba_data/python/bascenev1/_coopsession.py b/dist/ba_data/python/bascenev1/_coopsession.py index 68a7ed2..a3735ef 100644 --- a/dist/ba_data/python/bascenev1/_coopsession.py +++ b/dist/ba_data/python/bascenev1/_coopsession.py @@ -398,8 +398,7 @@ class CoopSession(Session): else: raise RuntimeError('FIXME') else: - if results.scoretype is not ScoreType.POINTS: - print(f'Unknown ScoreType:' f' "{results.scoretype}"') + assert results.scoretype is ScoreType.POINTS scoretype = 'points' # Old coop-game-specific results; should migrate away from these. diff --git a/dist/ba_data/python/bascenev1/_hooks.py b/dist/ba_data/python/bascenev1/_hooks.py index e85d350..ea06e60 100644 --- a/dist/ba_data/python/bascenev1/_hooks.py +++ b/dist/ba_data/python/bascenev1/_hooks.py @@ -35,10 +35,8 @@ def get_player_icon(sessionplayer: bascenev1.SessionPlayer) -> dict[str, Any]: def filter_chat_message(msg: str, client_id: int) -> str | None: try: - print("importing custom_hooks") import custom_hooks as chooks - except Exception as e: - print(e) + except: pass """Intercept/filter chat messages. diff --git a/dist/ba_data/python/bascenev1/_net.py b/dist/ba_data/python/bascenev1/_net.py index a67ca11..2988fc6 100644 --- a/dist/ba_data/python/bascenev1/_net.py +++ b/dist/ba_data/python/bascenev1/_net.py @@ -2,11 +2,41 @@ # """Functionality related to net play.""" +import os +import json +import socket +import asyncio +import logging from typing import TYPE_CHECKING -from dataclasses import dataclass +from dataclasses import dataclass, field + +import babase + +import _bascenev1 if TYPE_CHECKING: - pass + from typing import Any + +netlog = logging.getLogger('ba.net') + +# Wire packet-type bytes; must match BA_PACKET_HOST_REQUIREMENTS_QUERY +# / _RESPONSE in ballistica/base/networking/networking.h. +_REQS_QUERY_PACKET_TYPE = 40 +_REQS_RESPONSE_PACKET_TYPE = 41 + +# The requirements exchange rides lossy UDP, so each page is retried a +# few times with short waits. A host that never answers is taken to be +# a legacy host predating the protocol (which by definition has no +# requirements). +_REQS_ATTEMPT_TIMEOUT = 0.75 +_REQS_PAGE_ATTEMPTS = 3 + +# Refuse to chase absurd page counts from a hostile/buggy host. +_REQS_MAX_PAGES = 64 + +# The in-flight pre-join task, if any (see connect_to_party's +# latest-wins behavior). +_g_prejoin_task: asyncio.Task[None] | None = None @dataclass @@ -21,3 +51,309 @@ class HostInfo: # Note this can be None for non-ip hosts such as bluetooth. port: int | None + + +@dataclass +class HostRequirements: + """Everything a host requires of clients joining it. + + Fetched from prospective hosts by the pre-join requirements query + (see :func:`connect_to_party`). + """ + + asset_packages: list[str] = field(default_factory=list) + password_required: bool = False + + +def fetch_host_requirements(address: str, port: int) -> HostRequirements | None: + """Fetch join requirements from a prospective host. + + Speaks the paged UDP requirements-query protocol (fragments merge + across pages: lists concatenate, scalars are first-seen). Blocking + (network waits up to a few seconds); call from a background thread. + + Returns None when the host never answers -- either a legacy host + predating the protocol (nothing to require) or an unreachable/bogus + address (in which case the subsequent connect attempt surfaces the + error the user actually cares about). + """ + try: + infos = socket.getaddrinfo(address, port, type=socket.SOCK_DGRAM) + except OSError: + # Unresolvable address; let the real connect path report that. + return None + family, stype, proto, _canonname, sockaddr = infos[0] + + # Values here are parsed json, hence Any. + merged: dict[str, Any] = {} + page = 0 + page_count: int | None = None + try: + with socket.socket(family, stype, proto) as sock: + # Connecting the socket pins the peer address, so the kernel + # filters out datagrams from anyone but the host we asked. + sock.connect(sockaddr) + sock.settimeout(_REQS_ATTEMPT_TIMEOUT) + while page_count is None or page < page_count: + result = _fetch_requirements_page(sock, page) + if result is None: + return None + resp_page_count, fragment = result + if page_count is None: + page_count = min(resp_page_count, _REQS_MAX_PAGES) + for key, val in fragment.items(): + if isinstance(val, list): + merged.setdefault(key, []).extend(val) + else: + merged.setdefault(key, val) + page += 1 + except OSError: + return None + + asset_packages = merged.get('ap') + if not isinstance(asset_packages, list): + asset_packages = [] + return HostRequirements( + asset_packages=[pkg for pkg in asset_packages if isinstance(pkg, str)], + password_required=bool(merged.get('pw')), + ) + + +def _fetch_requirements_page( + sock: socket.socket, page: int +) -> tuple[int, dict[str, Any]] | None: + """Fetch a single requirements page over a connected UDP socket. + + Returns ``(page_count, requirements_fragment)``, or None if the + host never produced a valid response for this page. + """ + query = json.dumps( + { + 'v': 1, + 'b': babase.app.env.engine_build_number, + 'p': page, + }, + separators=(',', ':'), + ).encode() + for _attempt in range(_REQS_PAGE_ATTEMPTS): + query_id = os.urandom(4) + try: + sock.send(bytes([_REQS_QUERY_PACKET_TYPE]) + query_id + query) + while True: + data = sock.recv(1500) + if ( + len(data) >= 5 + and data[0] == _REQS_RESPONSE_PACKET_TYPE + and data[1:5] == query_id + ): + break + except TimeoutError, OSError: + continue + + # Got a response to *this* query; validate it. A host serving + # malformed data won't improve with retries, so treat that the + # same as no response. + try: + response = json.loads(data[5:]) + except ValueError: + return None + if not isinstance(response, dict): + return None + version = response.get('v') + if not isinstance(version, int) or version < 1: + return None + resp_page_count = response.get('n') + fragment = response.get('r') + if ( + response.get('p') != page + or not isinstance(resp_page_count, int) + or resp_page_count < 1 + or not isinstance(fragment, dict) + ): + return None + return resp_page_count, fragment + return None + + +def connect_to_party( + address: str, port: int = 43210, print_progress: bool = True +) -> None: + """Attempt to connect to a party at a given address. + + Runs the pre-join requirements exchange first: the prospective host + is asked what it requires of joiners (its asset-package listing, + etc.) and anything not yet locally available is downloaded -- with + a cancelable progress dialog -- before the actual connection + attempt happens. Hosts predating the requirements protocol get a + plain immediate connect. + + (internal) + """ + assert babase.in_logic_thread() + + # Latest-wins: a new connect request cancels any pre-join exchange + # still in flight (the user clicked a different party). + global _g_prejoin_task # pylint: disable=global-statement + if _g_prejoin_task is not None and not _g_prejoin_task.done(): + _g_prejoin_task.cancel() + _g_prejoin_task = None + + babase.app.create_async_task( + _prejoin_and_connect(address, port, print_progress), + name=f'connect_to_party {address}:{port}', + ) + + +class _Cancelled: + """Sentinel: the user aborted the password prompt.""" + + +async def _password_gate(address: str, port: int) -> str | _Cancelled: + """Run the pre-join password prompt. + + Returns the entered password (delivered to the host as an + HMAC-over-salt proof in the native connect path), or a + :class:`_Cancelled` sentinel if the user backed out / no UI was + available to prompt. + """ + try: + password = await babase.app.ui_v1.get_password() + except asyncio.CancelledError: + netlog.debug('Pre-join password prompt cancelled.') + return _Cancelled() + if password is None: + # None covers both an explicit user cancel and + # no-interactive-UI-available; the latter deserves a log since + # nothing was ever shown on screen. + if babase.app.env.gui: + netlog.info('Pre-join password entry cancelled; aborting join.') + else: + netlog.warning( + 'Host %s:%d requires a password; cannot prompt without a' + ' UI. Aborting join.', + address, + port, + ) + return _Cancelled() + return password + + +async def _prejoin_and_connect( + address: str, port: int, print_progress: bool +) -> None: + """Requirements exchange + content downloads + the actual connect.""" + global _g_prejoin_task # pylint: disable=global-statement + task = asyncio.current_task() + _g_prejoin_task = task + password = '' + try: + try: + requirements = await babase.app.asyncio_loop.run_in_executor( + babase.app.threadpool, fetch_host_requirements, address, port + ) + except asyncio.CancelledError: + netlog.debug('Pre-join requirements fetch cancelled.') + return + + if requirements is None: + netlog.debug( + 'No requirements response from %s:%d;' + ' assuming legacy host with none.', + address, + port, + ) + if requirements is not None and requirements.password_required: + # Password gate runs first: no point downloading content for + # a join the user then declines to enter a password for. + gate_result = await _password_gate(address, port) + if isinstance(gate_result, _Cancelled): + return + password = gate_result + if requirements is not None and requirements.asset_packages: + netlog.debug( + 'Host %s:%d requires %d asset-package(s); resolving.', + address, + port, + len(requirements.asset_packages), + ) + dialog: babase.SimpleDialog | None = None + + def on_cancel() -> None: + if task is not None: + task.cancel() + + def ensure_dialog() -> None: + # Lazily shown only if a real download begins (the + # everything-already-local case stays instant with no + # dialog flash). + nonlocal dialog + if dialog is None and babase.app.env.gui: + dialog = babase.SimpleDialog( + title=babase.Lstr(resource='updatingText'), + progress=0.0, + button_label=babase.Lstr(resource='cancelText'), + on_button=on_cancel, + ) + + def on_update(message: str, progress: float | None) -> None: + if dialog is not None: + dialog.update( + message=message, + progress=0.0 if progress is None else progress, + ) + + try: + await babase.app.assets.resolve( + requirements.asset_packages, + allow_downloads=True, + on_download_starting=ensure_dialog, + on_progress=babase.make_progress_reporter(on_update), + ) + except asyncio.CancelledError: + # User hit cancel (or clicked another party) -- bow out + # of the whole join. + if dialog is not None: + dialog.dismiss() + netlog.info('Pre-join content download cancelled.') + return + except Exception: + # Per the no-mid-game-downloads design, joining without + # the host's content would just strand us at the + # session entry check -- so fail the join cleanly here. + netlog.exception( + 'Pre-join content resolve failed for %s:%d.', + address, + port, + ) + if dialog is not None: + dialog.update( + title=babase.Lstr(resource='errorText'), + message=babase.Lstr( + resource='internal.unavailableNoConnectionText' + ), + progress=None, + button_label=babase.Lstr(resource='okText'), + on_button=dialog.dismiss, + ) + else: + babase.screenmessage( + babase.Lstr( + resource='internal.unavailableNoConnectionText' + ), + color=(1, 0, 0), + ) + return + if dialog is not None: + dialog.dismiss() + + # Requirements are met (or the host has none); on to the + # actual connection attempt. + _bascenev1.connect_to_party( + address, + port=port, + print_progress=print_progress, + password=password, + ) + finally: + if _g_prejoin_task is task: + _g_prejoin_task = None diff --git a/dist/ba_data/python/bascenev1/_profile.py b/dist/ba_data/python/bascenev1/_profile.py index 90e2ced..bfaa4cd 100644 --- a/dist/ba_data/python/bascenev1/_profile.py +++ b/dist/ba_data/python/bascenev1/_profile.py @@ -79,7 +79,7 @@ def get_player_profile_colors( try: assert profilename is not None color = profiles[profilename]['color'] - except (KeyError, AssertionError): + except KeyError, AssertionError: # Key off name if possible. if profilename is None: # First 6 are bright-ish. @@ -91,7 +91,7 @@ def get_player_profile_colors( try: assert profilename is not None highlight = profiles[profilename]['highlight'] - except (KeyError, AssertionError): + except KeyError, AssertionError: # Key off name if possible. if profilename is None: # Last 2 are grey and white; ignore those or we diff --git a/dist/ba_data/python/bascenev1/builtinassets.py b/dist/ba_data/python/bascenev1/builtinassets.py index 3cba6ab..7f47e44 100644 --- a/dist/ba_data/python/bascenev1/builtinassets.py +++ b/dist/ba_data/python/bascenev1/builtinassets.py @@ -1,7 +1,7 @@ # Released under the MIT License. See LICENSE for details. # # Auto-generated; do not edit by hand. -"""Asset-package wrapper for ``a-0.babuiltinassets.260622`` (bascenev1). +"""Asset-package wrapper for ``a-0.babuiltinassets.260719g`` (bascenev1). Bare minimum assets always bundled with the engine. @@ -9,13 +9,13 @@ These are loaded at launch and always available in the C++ layer. """ # ba_meta require api 9 -# ba_meta require asset-package a-0.babuiltinassets.260622 +# ba_meta require asset-package a-0.babuiltinassets.260719g # pylint: disable=useless-suppression # pylint: disable=too-many-lines # pylint: disable=too-few-public-methods, disallowed-name -__asset_package__ = 'a-0.babuiltinassets.260622' +__asset_package__ = 'a-0.babuiltinassets.260719g' from typing import TYPE_CHECKING @@ -25,7 +25,12 @@ if TYPE_CHECKING: import bascenev1 class AudioGroup: - """Asset-group type; see source for the full list.""" + """ + Sounds needed during engine bootstrap and early UI (clicks, errors, and + other always-available effects). + + See source for the full asset list. + """ blank: bascenev1.Sound blip: bascenev1.Sound @@ -49,7 +54,11 @@ if TYPE_CHECKING: ticking_crazy: bascenev1.Sound class MeshesGroup: - """Asset-group type; see source for the full list.""" + """ + Meshes needed during engine bootstrap and early UI. + + See source for the full asset list. + """ action_button_bottom: bascenev1.Mesh action_button_left: bascenev1.Mesh @@ -125,7 +134,12 @@ if TYPE_CHECKING: wing: bascenev1.Mesh class TexturesGroup: - """Asset-group type; see source for the full list.""" + """ + Textures needed during engine bootstrap and early UI, including the + reflection cube-maps. + + See source for the full asset list. + """ action_buttons: bascenev1.Texture arrow: bascenev1.Texture diff --git a/dist/ba_data/python/bascenev1/stdassets.py b/dist/ba_data/python/bascenev1/stdassets.py index 498f517..5d923a7 100644 --- a/dist/ba_data/python/bascenev1/stdassets.py +++ b/dist/ba_data/python/bascenev1/stdassets.py @@ -1,19 +1,19 @@ # Released under the MIT License. See LICENSE for details. # # Auto-generated; do not edit by hand. -"""Asset-package wrapper for ``a-0.bastdassets.260622`` (bascenev1). +"""Asset-package wrapper for ``a-0.bastdassets.260720`` (bascenev1). All assets for classic bombsquad. """ # ba_meta require api 9 -# ba_meta require asset-package a-0.bastdassets.260622 +# ba_meta require asset-package a-0.bastdassets.260720 # pylint: disable=useless-suppression # pylint: disable=too-many-lines # pylint: disable=too-few-public-methods, disallowed-name -__asset_package__ = 'a-0.bastdassets.260622' +__asset_package__ = 'a-0.bastdassets.260720' from typing import TYPE_CHECKING @@ -23,7 +23,11 @@ if TYPE_CHECKING: import bascenev1 class AudioGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game sounds (everything non-bootstrap). + + See source for the full asset list. + """ achievement: bascenev1.Sound action_hero1: bascenev1.Sound @@ -439,7 +443,11 @@ if TYPE_CHECKING: zoe_scream01: bascenev1.Sound class MeshesGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game meshes (everything non-bootstrap). + + See source for the full asset list. + """ achievement_outline: bascenev1.Mesh action_hero_fore_arm: bascenev1.Mesh @@ -833,7 +841,11 @@ if TYPE_CHECKING: zoe_upper_leg: bascenev1.Mesh class TexturesGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game textures (everything non-bootstrap). + + See source for the full asset list. + """ achievement_boxer: bascenev1.Texture achievement_cross_hair: bascenev1.Texture diff --git a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py index b1b6a56..6e604ad 100644 --- a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py @@ -147,7 +147,6 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): kill_delay: float, shiftdelay: float, ) -> None: - # pylint: disable=too-many-positional-arguments del kill_delay # Unused arg. ZoomText( str(sessionteam.customdata['score']), diff --git a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py index 4eea8fa..7380c3a 100644 --- a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py +++ b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py @@ -384,7 +384,6 @@ class Scoreboard: Label can be something like 'points' and will show up on boards if provided. """ - # pylint: disable=too-many-positional-arguments self._flat_tex = stdassets.textures.null self._entries: dict[int, _Entry] = {} self._label = label diff --git a/dist/ba_data/python/bascenev1lib/game/onslaught.py b/dist/ba_data/python/bascenev1lib/game/onslaught.py index 202396a..c315bd5 100644 --- a/dist/ba_data/python/bascenev1lib/game/onslaught.py +++ b/dist/ba_data/python/bascenev1lib/game/onslaught.py @@ -810,7 +810,6 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]): max_level: int, ) -> list[list[tuple[int, int]]]: """Calculate a distribution of bad guys given some params.""" - # pylint: disable=too-many-positional-arguments max_iterations = 10 + max_dudes * 2 groups: list[list[tuple[int, int]]] = [] diff --git a/dist/ba_data/python/bauiv1/__init__.py b/dist/ba_data/python/bauiv1/__init__.py index 46f20d5..1e0dcad 100644 --- a/dist/ba_data/python/bauiv1/__init__.py +++ b/dist/ba_data/python/bauiv1/__init__.py @@ -81,6 +81,7 @@ from babase import ( lock_all_input, LoginAdapter, LoginInfo, + LangStr, Lstr, native_review_request, native_review_request_supported, @@ -249,6 +250,7 @@ __all__ = [ 'lock_all_input', 'LoginAdapter', 'LoginInfo', + 'LangStr', 'Lstr', 'MainWindow', 'MainWindowAutoRecreateSuppress', diff --git a/dist/ba_data/python/bauiv1/_appsubsystem.py b/dist/ba_data/python/bauiv1/_appsubsystem.py index 00039a9..0bf1daf 100644 --- a/dist/ba_data/python/bauiv1/_appsubsystem.py +++ b/dist/ba_data/python/bauiv1/_appsubsystem.py @@ -127,6 +127,49 @@ class UIV1AppSubsystem(babase.AppSubsystem): """ return _bauiv1.is_available() + async def get_password( + self, *, description: str | babase.Lstr | babase.LangStr | None = None + ) -> str | None: + """Ask the user for a password. + + Returns the entered password, or None if the user cancels or no + interactive UI is available (headless, etc.). Overridable + ('virtual') so alternate UI layers can substitute their own + prompt; this default implementation shows a small + :class:`~bauiv1lib.passwordprompt.PasswordPromptWindow`. + + Must be awaited on the logic thread. If the awaiting task is + cancelled, the prompt window is dismissed. + """ + import asyncio + + assert babase.in_logic_thread() + if not babase.app.env.gui or not self.available: + return None + + # Deferred up-call into our window library; bauiv1lib is fully + # importable by the time an interactive UI can invoke this, so + # the cycle is structural only. + # pylint: disable-next=cyclic-import + from bauiv1lib.passwordprompt import PasswordPromptWindow + + fut: asyncio.Future[str | None] = ( + babase.app.asyncio_loop.create_future() + ) + + def _on_result(result: str | None) -> None: + if not fut.done(): + fut.set_result(result) + + window = PasswordPromptWindow( + description=description, on_result=_on_result + ) + try: + return await fut + except asyncio.CancelledError: + window.dismiss() + raise + @override def reset(self) -> None: from bauiv1._window import MainWindow diff --git a/dist/ba_data/python/bauiv1/_assetref.py b/dist/ba_data/python/bauiv1/_assetref.py index 580ba07..42dc567 100644 --- a/dist/ba_data/python/bauiv1/_assetref.py +++ b/dist/ba_data/python/bauiv1/_assetref.py @@ -2,31 +2,32 @@ # """Runtime support for generated bauiv1 asset-*reference* wrappers. -This is the bauiv1 (client) flavor of :mod:`bacommon.assetref`. A -generated reference wrapper exposes per-kind roots (``textures``, -``meshes``, ...) whose leaves are language-independent references -(:class:`~bacommon.assetref.TextureRef` / :class:`~bacommon.assetref.MeshRef`) -suitable for authoring doc-ui-v2 documents. Where the server-side wrapper -(:mod:`bacommon.assetref`) yields the bare ``bacommon`` reference types, the -client wants those references to *also* be loadable into real engine assets -for low-level UI calls. So this module's leaves are thin subclasses that add -a single :meth:`TextureRef.get` method returning the live ``bauiv1.Texture`` -(etc.) while remaining ordinary references on the wire. +This is the bauiv1 (client) flavor of :mod:`bacommon.assetref` and the +middle tier of the D28 asset ladder: ``TextureSpec`` (authoring claim) +-> ``TextureRef`` (this module; a *verified-local* reference — its +wrapper's pin was construct-mode-resolved before use) -> +``bauiv1.Texture`` (the loaded engine asset). A generated reference +wrapper exposes per-kind roots (``textures``, ``meshes``, ...) whose +leaves here are thin subclasses of the spec types adding a single +:meth:`TextureRef.get` method returning the live ``bauiv1.Texture`` +(etc.) while remaining ordinary specs on the wire. -The subclasses add no data fields -- only the ``get()`` accessor -- so an -instance serializes identically to its ``bacommon`` base and decodes back as +The subclasses add no data fields -- only the ``get()`` accessor -- so +an instance serializes identically to its spec base and decodes back as the plain base type on the far end (the subclass is an authoring-side -convenience only). This is the inverse of inheriting a field-less abstract -base; it stays within dataclassio's rules (a nested-dataclass field accepts -any ``isinstance`` of its annotated type). +convenience only; verified -> spec is the always-valid direction, here +via plain inheritance rather than langstr's ``.spec`` projection). This +is the inverse of inheriting a field-less abstract base; it stays +within dataclassio's rules (a nested-dataclass field accepts any +``isinstance`` of its annotated type). """ from typing import TYPE_CHECKING from bacommon.assetref import ( - TextureRef as _TextureRef, - MeshRef as _MeshRef, - SoundRef as _SoundRef, + TextureSpec as _TextureSpec, + MeshSpec as _MeshSpec, + SoundSpec as _SoundSpec, ) if TYPE_CHECKING: @@ -36,7 +37,7 @@ if TYPE_CHECKING: # These leaves add only a ``get()`` method (no new fields), so they need no # ``@dataclass`` -- they inherit the base's fields, ``__init__``, ``__eq__``, # etc., serialize byte-for-byte as the base, and decode back as the base. -class TextureRef(_TextureRef): +class TextureRef(_TextureSpec): """A texture reference that can also load the live engine texture.""" def get(self) -> 'bauiv1.Texture': @@ -46,7 +47,7 @@ class TextureRef(_TextureRef): return bauiv1.gettexture(f'{self.apverid}:{self.name}') -class MeshRef(_MeshRef): +class MeshRef(_MeshSpec): """A mesh reference that can also load the live engine mesh.""" def get(self) -> 'bauiv1.Mesh': @@ -56,7 +57,7 @@ class MeshRef(_MeshRef): return bauiv1.getmesh(f'{self.apverid}:{self.name}') -class SoundRef(_SoundRef): +class SoundRef(_SoundSpec): """A sound reference that can also load the live engine sound.""" def get(self) -> 'bauiv1.Sound': diff --git a/dist/ba_data/python/bauiv1/_docuiv2testassets.py b/dist/ba_data/python/bauiv1/_docuiv2testassets.py new file mode 100644 index 0000000..55f0b84 --- /dev/null +++ b/dist/ba_data/python/bauiv1/_docuiv2testassets.py @@ -0,0 +1,551 @@ +# Released under the MIT License. See LICENSE for details. +# +# Auto-generated; do not edit by hand. +"""Asset-package wrapper for ``a-0.badocuiv2testassets.260718a`` (bauiv1).""" + +# ba_meta require api 9 +# ba_meta require asset-package a-0.badocuiv2testassets.260718a + +# pylint: disable=useless-suppression +# pylint: disable=too-many-lines +# pylint: disable=too-few-public-methods, disallowed-name + +__asset_package__ = 'a-0.badocuiv2testassets.260718a' + +from typing import TYPE_CHECKING + +from babase import LangStrDir + +if TYPE_CHECKING: + from babase import LangStr + + class StringsCloudGroup: + """ + Cloud-message test page strings. + + See source for the full asset list. + """ + + #: Body text on the cloud-message test page. + #: + #: English: "This page came from the cloud." + came_from_cloud: LangStr + + #: Button requesting a test page via a cloud message. + #: + #: English: "Cloud-Msg GET" + cloud_msg_get: LangStr + + #: Button posting a test action via a cloud message. + #: + #: English: "Cloud-Msg POST" + cloud_msg_post: LangStr + + #: Title of the cloud-message test page. + #: + #: English: "Cloud Test" + cloud_test: LangStr + + class StringsCommonGroup: + """ + Greetings, debug toggles, and shared bits. + + See source for the full asset list. + """ + + def code_literal(self, *, text: str | LangStr) -> LangStr: + """ + Verbatim passthrough for code identifiers (button style names etc.) + on test pages. + + English: "{text}" + """ + + #: Developer note on the test root page. + #: + #: English: "Use this as a reference for building UIs with DocUI. Its + #: code lives at bauiv1lib.docuitest." + docui_reference: LangStr + + #: Subtitle on the timed-actions test page. + #: + #: English: "Each change here is a new request/response." + each_change: LangStr + + #: Placeholder label for layout tests. + #: + #: English: "foo" + foo: LangStr + + #: Debug marker for the left header slot. + #: + #: English: "HeaderLeft" + header_left: LangStr + + #: Debug marker for the right header slot. + #: + #: English: "HeaderRight" + header_right: LangStr + + #: Greeting text at the top of the test root page. + #: + #: English: "Hello from DocUI!" + hello_from_docui: LangStr + + #: Screen-message from the centered-content test button. + #: + #: English: "Hello There!" + hello_there: LangStr + + def hello_there_num(self, *, num: str | LangStr) -> LangStr: + """ + Row title on the timed-actions page; {num} increments with each + timed update. + + English: "Hello There {num}" + """ + + #: Button toggling layout-debug decorations off. + #: + #: English: "Hide Debug" + hide_debug: LangStr + + #: Button deliberately sending a malformed request to test error + #: handling. + #: + #: English: "Invalid Request" + invalid_request: LangStr + + #: Button toggling layout-debug decorations on. + #: + #: English: "Show Debug" + show_debug: LangStr + + #: Wry button label on the slow-load test page. + #: + #: English: "Sure Did" + sure_did: LangStr + + #: Row title shown after the deliberately slow page loads. + #: + #: English: "That Took a While" + that_took_a_while: LangStr + + #: Button opening the timed-actions test page. + #: + #: English: "Timed Actions" + timed_actions: LangStr + + def you_are(self, *, name: str | LangStr) -> LangStr: + """ + Account-name line on a server-driven test page. + + English: "You are: {name}" + """ + + class StringsEffectsGroup: + """ + Client-effect and local-action test buttons and messages. + + See source for the full asset list. + """ + + #: Screen-message confirming a test effect/action ran. + #: + #: English: "Success!" + effect_success: LangStr + + #: Button firing client-effects immediately on press (no request + #: round-trip). + #: + #: English: "Immediate ClientEffects" + immediate_client_effects: LangStr + + #: Screen-message fired by the immediate client-effects test button. + #: + #: English: "Hello From Immediate Client Effects" + immediate_effects_hello: LangStr + + #: Button firing a local action immediately on press. + #: + #: English: "Immediate Local Action" + immediate_local_action: LangStr + + #: Button whose response carries client-effects to run. + #: + #: English: "Response Client Effects" + response_client_effects: LangStr + + #: Screen-message fired by the response client-effects test button. + #: + #: English: "Hello From Response Client Effects" + response_effects_hello: LangStr + + #: Button whose response carries a local action to run. + #: + #: English: "Response Local Action" + response_local_action: LangStr + + class StringsItemsGroup: + """ + Display-item test page strings. + + See source for the full asset list. + """ + + #: Title of the display-item test page's row. + #: + #: English: "Display Item Tests" + display_item_tests: LangStr + + #: Button opening (and title of) the display-item test page. + #: + #: English: "Display Items" + display_items: LangStr + + #: Debug legend describing the display-item layout matrix. + #: + #: English: "top=FULL, center=COMPACT, bottom=ICON; left=regular, + #: right=unknown" + display_items_sub: LangStr + + class StringsLayoutGroup: + """ + Layout/bounds test strings and debug markers. + + See source for the full asset list. + """ + + #: Placeholder label on the bounds-test page background. + #: + #: English: "(background texture)" + background_texture: LangStr + + #: Button opening a single bounds test. + #: + #: English: "Bounds Test" + bounds_test: LangStr + + #: Button opening the bounds-tests page. + #: + #: English: "Bounds Tests" + bounds_tests: LangStr + + #: Title of the bounds-tests page. + #: + #: English: "Bounds Tests" + bounds_tests_title: LangStr + + #: Title of the centered-content layout test row. + #: + #: English: "Centered Content / Faded Title" + centered_faded_title: LangStr + + #: Corner-position marker (bottom-left) for layout debug. + #: + #: English: "BL" + corner_bl: LangStr + + #: Corner-position marker (bottom-right) for layout debug. + #: + #: English: "BR" + corner_br: LangStr + + #: Corner-position marker (top-left) for layout debug. + #: + #: English: "TL" + corner_tl: LangStr + + #: Corner-position marker (top-right) for layout debug. + #: + #: English: "TR" + corner_tr: LangStr + + #: Button opening the deliberately empty page. + #: + #: English: "Empty Page" + empty_page: LangStr + + #: Title of the deliberately empty test page. + #: + #: English: "Empty Page" + empty_page_title: LangStr + + #: Title of the deliberately empty button-row. + #: + #: English: "Empty Row" + empty_row: LangStr + + #: Sample button label repeated across button styles on the bounds-tests + #: page. + #: + #: English: "Hello" + hello: LangStr + + #: Title of the layout-tests button-row. + #: + #: English: "Layout Tests" + layout_tests: LangStr + + #: Title of the horizontally-scrolling long button-row. + #: + #: English: "Long Row Test" + long_row_test: LangStr + + #: Subtitle on the long-row layout test. + #: + #: English: "Look - a subtitle!" + look_a_subtitle: LangStr + + #: Debug marker exercising max-height/multi-line text layout. + #: + #: English: "MaxHeightTest SecondLine" + max_height_test: LangStr + + #: Debug marker exercising max-width text layout. + #: + #: English: "MaxWidthTest" + max_width_test: LangStr + + #: Button label inside the titleless-row layout test. + #: + #: English: "Row-With-No-Title Test" + row_with_no_title: LangStr + + #: Subtitle on the subtitle-only layout test row. + #: + #: English: "Subtitle only!" + subtitle_only: LangStr + + #: Subtitle on the centered-content layout test row. + #: + #: English: "Testing Centered Title/Content" + testing_centered: LangStr + + class StringsNavGroup: + """ + Page titles, row titles, and navigation buttons. + + See source for the full asset list. + """ + + #: Button opening a sub-page in browse (push) mode. + #: + #: English: "Browse" + browse: LangStr + + #: Button closing the test window. + #: + #: English: "Close" + close: LangStr + + #: Button dismissing the timed-actions test page. + #: + #: English: "Done" + done: LangStr + + #: Title of the third button-row on the root page. + #: + #: English: "Even More Tests" + even_more_tests: LangStr + + #: Title of the second button-row on the root page. + #: + #: English: "A Few More Tests" + few_more_tests: LangStr + + #: Title of test page 2's button-row. + #: + #: English: "More Tests" + more_tests: LangStr + + #: Button loading a page in replace mode (swaps the current page instead + #: of pushing). + #: + #: English: "Replace" + replace: LangStr + + #: Button opening a slow-loading sub-page in browse mode (exercises the + #: loading state). + #: + #: English: "Slow Browse" + slow_browse: LangStr + + #: Button loading a slow page in replace mode. + #: + #: English: "Slow Replace" + slow_replace: LangStr + + #: Title of the first button-row on the root page. + #: + #: English: "Some Tests" + some_tests: LangStr + + #: Generic test button label; also titles the slow-load and + #: timed-actions pages. + #: + #: English: "Test" + test: LangStr + + #: Title of the docui-v2 test root page. + #: + #: English: "Test Root" + test_root_title: LangStr + + #: Another generic test button. + #: + #: English: "Test 3" + test_three: LangStr + + #: Button opening test page 2. + #: + #: English: "Test 2" + test_two: LangStr + + #: Title of test page 2. + #: + #: English: "Test 2" + test_two_title: LangStr + + class StringsWebGroup: + """ + Web-request test page strings. + + See source for the full asset list. + """ + + def came_from_web(self, *, method: str | LangStr) -> LangStr: + """ + Body text on the web-request test page; {method} is the literal HTTP + method used. + + English: "This page came from a web {method} request." + """ + + #: Button requesting a test page via a web GET request. + #: + #: English: "Web GET" + web_get: LangStr + + #: Button requesting a test page via a web POST request. + #: + #: English: "Web POST" + web_post: LangStr + + #: Title of the web-request test page. + #: + #: English: "Web Test" + web_test: LangStr + + class StringsGroup: + """ + Strings for the docui-v2 test UI (bauiv1lib.docuitest plus the master + server's test pages) - a working reference for DocUI development. + + See source for the full asset list. + """ + + cloud: StringsCloudGroup + common: StringsCommonGroup + effects: StringsEffectsGroup + items: StringsItemsGroup + layout: StringsLayoutGroup + nav: StringsNavGroup + web: StringsWebGroup + + #: The ``strings`` group - 70 strings (``cloud``, ``common``, ``effects``, + #: ``items``, ``layout``, and 65 more). Full list in source. + strings: StringsGroup + +_TREE = { + 'strings': { + 'cloud': { + 'came_from_cloud': (), + 'cloud_msg_get': (), + 'cloud_msg_post': (), + 'cloud_test': (), + }, + 'common': { + 'code_literal': ('text',), + 'docui_reference': (), + 'each_change': (), + 'foo': (), + 'header_left': (), + 'header_right': (), + 'hello_from_docui': (), + 'hello_there': (), + 'hello_there_num': ('num',), + 'hide_debug': (), + 'invalid_request': (), + 'show_debug': (), + 'sure_did': (), + 'that_took_a_while': (), + 'timed_actions': (), + 'you_are': ('name',), + }, + 'effects': { + 'effect_success': (), + 'immediate_client_effects': (), + 'immediate_effects_hello': (), + 'immediate_local_action': (), + 'response_client_effects': (), + 'response_effects_hello': (), + 'response_local_action': (), + }, + 'items': { + 'display_item_tests': (), + 'display_items': (), + 'display_items_sub': (), + }, + 'layout': { + 'background_texture': (), + 'bounds_test': (), + 'bounds_tests': (), + 'bounds_tests_title': (), + 'centered_faded_title': (), + 'corner_bl': (), + 'corner_br': (), + 'corner_tl': (), + 'corner_tr': (), + 'empty_page': (), + 'empty_page_title': (), + 'empty_row': (), + 'hello': (), + 'layout_tests': (), + 'long_row_test': (), + 'look_a_subtitle': (), + 'max_height_test': (), + 'max_width_test': (), + 'row_with_no_title': (), + 'subtitle_only': (), + 'testing_centered': (), + }, + 'nav': { + 'browse': (), + 'close': (), + 'done': (), + 'even_more_tests': (), + 'few_more_tests': (), + 'more_tests': (), + 'replace': (), + 'slow_browse': (), + 'slow_replace': (), + 'some_tests': (), + 'test': (), + 'test_root_title': (), + 'test_three': (), + 'test_two': (), + 'test_two_title': (), + }, + 'web': { + 'came_from_web': ('method',), + 'web_get': (), + 'web_post': (), + 'web_test': (), + }, + } +} + + +if not TYPE_CHECKING: + strings = LangStrDir(__asset_package__, _TREE['strings'], 'strings') diff --git a/dist/ba_data/python/bauiv1/_uitypes.py b/dist/ba_data/python/bauiv1/_uitypes.py index 446d0ad..6b9e87a 100644 --- a/dist/ba_data/python/bauiv1/_uitypes.py +++ b/dist/ba_data/python/bauiv1/_uitypes.py @@ -45,11 +45,17 @@ class TextWidgetStringEditAdapter(babase.StringEditAdapter): assert isinstance(initial_text, str) max_length: Any = _bauiv1.textwidget(query_max_chars=text_widget) assert isinstance(max_length, int) + is_password: Any = _bauiv1.textwidget(query_password=text_widget) + assert isinstance(is_password, bool) screen_space_center = text_widget.get_screen_space_center() super().__init__( - description, initial_text, max_length, screen_space_center + description, + initial_text, + max_length, + screen_space_center, + is_password=is_password, ) @override diff --git a/dist/ba_data/python/bauiv1/builtinassets.py b/dist/ba_data/python/bauiv1/builtinassets.py index 4fdc546..b71ec5f 100644 --- a/dist/ba_data/python/bauiv1/builtinassets.py +++ b/dist/ba_data/python/bauiv1/builtinassets.py @@ -1,7 +1,7 @@ # Released under the MIT License. See LICENSE for details. # # Auto-generated; do not edit by hand. -"""Asset-package wrapper for ``a-0.babuiltinassets.260622`` (bauiv1). +"""Asset-package wrapper for ``a-0.babuiltinassets.260719g`` (bauiv1). Bare minimum assets always bundled with the engine. @@ -9,23 +9,31 @@ These are loaded at launch and always available in the C++ layer. """ # ba_meta require api 9 -# ba_meta require asset-package a-0.babuiltinassets.260622 +# ba_meta require asset-package a-0.babuiltinassets.260719g # pylint: disable=useless-suppression # pylint: disable=too-many-lines # pylint: disable=too-few-public-methods, disallowed-name -__asset_package__ = 'a-0.babuiltinassets.260622' +__asset_package__ = 'a-0.babuiltinassets.260719g' from typing import TYPE_CHECKING from bauiv1._assetref import AssetRefDir +from babase import LangStrDir + if TYPE_CHECKING: from bauiv1._assetref import MeshRef, SoundRef, TextureRef + from babase import LangStr class AudioGroup: - """Asset-group type; see source for the full list.""" + """ + Sounds needed during engine bootstrap and early UI (clicks, errors, and + other always-available effects). + + See source for the full asset list. + """ blank: SoundRef blip: SoundRef @@ -49,7 +57,11 @@ if TYPE_CHECKING: ticking_crazy: SoundRef class MeshesGroup: - """Asset-group type; see source for the full list.""" + """ + Meshes needed during engine bootstrap and early UI. + + See source for the full asset list. + """ action_button_bottom: MeshRef action_button_left: MeshRef @@ -124,8 +136,281 @@ if TYPE_CHECKING: window_hsmall_vsmall_transparent: MeshRef wing: MeshRef + class StringsAudioGroup: + """ + Audio-related messages: music/custom-soundtrack playback errors. + + See source for the full asset list. + """ + + def music_play_error(self, *, music: str | LangStr) -> LangStr: + """ + Error screen-message shown when a custom-soundtrack music file fails + to play; the placeholder is the quoted filename. + + English: "Error playing music: {music}" + """ + + class StringsInputGroup: + """ + Input-device strings: device display names and connect/disconnect + notices. + + See source for the full asset list. + """ + + def axis(self, *, number: str | LangStr) -> LangStr: + """ + Short lowercase label identifying a numbered joystick axis by index; + used inline in axis-name displays such as the controls-configuration + UI. The {number} placeholder is the axis index. + + English: "axis {number}" + """ + + def button(self, *, number: str | LangStr) -> LangStr: + """ + Short lowercase label identifying a numbered controller button by + index; used inline in button-name displays such as the + controls-configuration UI. The {number} placeholder is the button + index. + + English: "button {number}" + """ + + def controller_connected(self, *, controller: str | LangStr) -> LangStr: + """ + Transient screen-message shown when a single game controller + connects, naming the device (several connecting at once use a + separate counted message). + + English: "{controller} connected." + """ + + #: Transient screen-message shown at app startup when exactly one game + #: controller is detected (multiple controllers at startup use a + #: separate counted message). + #: + #: English: "1 controller detected." + controller_detected: LangStr + + def controller_disconnected( + self, *, controller: str | LangStr + ) -> LangStr: + """ + Transient screen-message shown when a single game controller + disconnects, naming the device (several disconnecting at once use a + separate counted message). + + English: "{controller} disconnected." + """ + + def controller_reconnected( + self, *, controller: str | LangStr + ) -> LangStr: + """ + Transient screen-message shown when a previously-connected game + controller (e.g. a BombSquad Remote phone client) reconnects, naming + the device. + + English: "{controller} reconnected." + """ + + def controllers_connected(self, *, count: int) -> LangStr: + """ + Transient screen-message shown when multiple game controllers + connect at the same time (a single controller connecting shows a + different message naming that controller). + + English: (one) "# controller connected." / (other) "# controllers + connected." + """ + + def controllers_detected(self, *, count: int) -> LangStr: + """ + Transient screen-message shown at app startup when more than one + game controller is detected at once (a single controller at startup + uses a separate message). + + English: (one) "# controller detected." / (other) "# controllers + detected." + """ + + def controllers_disconnected(self, *, count: int) -> LangStr: + """ + Transient screen-message shown when multiple game controllers + disconnect at the same time (a single controller disconnecting shows + a different message naming that controller). + + English: (one) "# controller disconnected." / (other) "# controllers + disconnected." + """ + + #: Display name for the keyboard input device; shown in input-device + #: lists, controls-configuration UI, and messages naming the device. + #: + #: English: "Keyboard" + keyboard: LangStr + + #: Display name for the touch-screen input device; shown in input-device + #: lists, controls-configuration UI, and messages naming the device. + #: + #: English: "TouchScreen" + touch_screen: LangStr + + #: Warning screen-message shown when the touchscreen joins the game + #: while physical controllers are already active (touch joins are often + #: accidental then); tells the player how to back out. 'Menu' and 'Leave + #: Game' refer to in-game menu items. + #: + #: English: "You have joined with the touchscreen. If this was a + #: mistake, tap Menu -> Leave Game with it to back out." + touch_screen_join_warning: LangStr + + #: Confirmation screen-message shown in VR mode when the player resets + #: the headset's forward orientation via their controller. + #: + #: English: "VR orientation reset." + vr_orientation_reset: LangStr + + class StringsNetGroup: + """ + Networking error messages shown to the player. + + See source for the full asset list. + """ + + #: Error screen-message shown when the player enters a malformed network + #: address trying to connect to a game party. + #: + #: English: "Error: invalid address." + invalid_address: LangStr + + class StringsReplayGroup: + """ + Game-replay playback error messages. + + See source for the full asset list. + """ + + #: Error screen-message shown when a game replay file can't be read + #: (corrupt or truncated). + #: + #: English: "Error reading replay file." + read_error: LangStr + + #: Error screen-message shown when a saved game replay was recorded by + #: an incompatible game version and can't be played back. + #: + #: English: "Sorry, this replay was made in a different version of the + #: game and can't be used." + version_error: LangStr + + class StringsSessionGroup: + """ + Gameplay-session messages shown by the host: idle-player kick notices + and similar. + + See source for the full asset list. + """ + + def kick_idle_kicked(self, *, name: str | LangStr) -> LangStr: + """ + Screen-message shown on the host when a player is removed from the + game for being idle too long (the kick-idle-players option). + + English: "Kicking {name} for being idle." + """ + + def kick_idle_warning( + self, *, seconds: int, name: str | LangStr + ) -> LangStr: + """ + Screen-message warning shown on the host shortly before an idle + player gets kicked (the kick-idle-players option); followed by the + kick_idle_warning_settings note. + + English: (one) "{name} will be kicked in # second if still idle." / + (other) "{name} will be kicked in # seconds if still idle." + """ + + #: Parenthesized note shown right after the kick_idle_warning message, + #: pointing at where the kick-idle-players behavior can be disabled. + #: 'Settings' and 'Advanced' refer to the in-game settings menu + #: sections. + #: + #: English: "(you can turn this off in Settings -> Advanced)" + kick_idle_warning_settings: LangStr + + class StringsUiGroup: + """ + General UI strings: menu-control ownership messages and list-navigation + hints. + + See source for the full asset list. + """ + + def arrows_to_exit_list( + self, *, left: str | LangStr, right: str | LangStr + ) -> LangStr: + """ + Lowercase hint shown (with an error sound) when the player hits the + edge of a UI list; tells them how to move focus out of it. The two + placeholders are substituted with left/right arrow glyph characters. + + English: "press {left} or {right} to exit list" + """ + + def has_menu_control(self, *, name: str | LangStr) -> LangStr: + """ + Screen-message shown when an input device tries to use a menu + another device currently controls; names the controlling device. A + timeout suffix (menu_control_time_out or menu_control_will_time_out) + is appended after it. + + English: "{name} has menu control." + """ + + def menu_control_time_out(self, *, seconds: int) -> LangStr: + """ + Parenthesized suffix appended after the has_menu_control message + once the controlling device's ownership is close to expiring; gives + the remaining seconds. + + English: (one) "(times out in # second)" / (other) "(times out in # + seconds)" + """ + + #: Parenthesized suffix appended after the has_menu_control message + #: while the controlling device's ownership is not yet close to + #: expiring. + #: + #: English: "(will time out if idle)" + menu_control_will_time_out: LangStr + + class StringsGroup: + """ + New-format engine strings needed early or accessed from the C++ layer + via the builtin-strings API (see ballistica-internal + strings-asset-migration decision D22). + + See source for the full asset list. + """ + + audio: StringsAudioGroup + input: StringsInputGroup + net: StringsNetGroup + replay: StringsReplayGroup + session: StringsSessionGroup + ui: StringsUiGroup + class TexturesGroup: - """Asset-group type; see source for the full list.""" + """ + Textures needed during engine bootstrap and early UI, including the + reflection cube-maps. + + See source for the full asset list. + """ action_buttons: TextureRef arrow: TextureRef @@ -219,6 +504,10 @@ if TYPE_CHECKING: #: ``arrow_back``, and 67 more). Full list in source. meshes: MeshesGroup + #: The ``strings`` group - 24 strings (``audio``, ``input``, ``net``, + #: ``replay``, ``session``, and 19 more). Full list in source. + strings: StringsGroup + #: The ``textures`` group - 82 assets (``action_buttons``, ``arrow``, #: ``back_icon``, ``black``, ``bomb_button``, and 77 more). Full list in #: source. @@ -321,6 +610,37 @@ _TREE = { 'window_hsmall_vsmall_transparent': 'm', 'wing': 'm', }, + 'strings': { + 'audio': {'music_play_error': ('music',)}, + 'input': { + 'axis': ('number',), + 'button': ('number',), + 'controller_connected': ('controller',), + 'controller_detected': (), + 'controller_disconnected': ('controller',), + 'controller_reconnected': ('controller',), + 'controllers_connected': ('count',), + 'controllers_detected': ('count',), + 'controllers_disconnected': ('count',), + 'keyboard': (), + 'touch_screen': (), + 'touch_screen_join_warning': (), + 'vr_orientation_reset': (), + }, + 'net': {'invalid_address': ()}, + 'replay': {'read_error': (), 'version_error': ()}, + 'session': { + 'kick_idle_kicked': ('name',), + 'kick_idle_warning': ('seconds', 'name'), + 'kick_idle_warning_settings': (), + }, + 'ui': { + 'arrows_to_exit_list': ('left', 'right'), + 'has_menu_control': ('name',), + 'menu_control_time_out': ('seconds',), + 'menu_control_will_time_out': (), + }, + }, 'textures': { 'action_buttons': 't', 'arrow': 't', @@ -411,4 +731,5 @@ _TREE = { if not TYPE_CHECKING: audio = AssetRefDir(__asset_package__, _TREE['audio'], 'audio') meshes = AssetRefDir(__asset_package__, _TREE['meshes'], 'meshes') + strings = LangStrDir(__asset_package__, _TREE['strings'], 'strings') textures = AssetRefDir(__asset_package__, _TREE['textures'], 'textures') diff --git a/dist/ba_data/python/bauiv1/stdassets.py b/dist/ba_data/python/bauiv1/stdassets.py index 7e309ff..b5a4bfd 100644 --- a/dist/ba_data/python/bauiv1/stdassets.py +++ b/dist/ba_data/python/bauiv1/stdassets.py @@ -1,29 +1,36 @@ # Released under the MIT License. See LICENSE for details. # # Auto-generated; do not edit by hand. -"""Asset-package wrapper for ``a-0.bastdassets.260622`` (bauiv1). +"""Asset-package wrapper for ``a-0.bastdassets.260720`` (bauiv1). All assets for classic bombsquad. """ # ba_meta require api 9 -# ba_meta require asset-package a-0.bastdassets.260622 +# ba_meta require asset-package a-0.bastdassets.260720 # pylint: disable=useless-suppression # pylint: disable=too-many-lines # pylint: disable=too-few-public-methods, disallowed-name -__asset_package__ = 'a-0.bastdassets.260622' +__asset_package__ = 'a-0.bastdassets.260720' from typing import TYPE_CHECKING from bauiv1._assetref import AssetRefDir +from babase import LangStrDir + if TYPE_CHECKING: from bauiv1._assetref import MeshRef, SoundRef, TextureRef + from babase import LangStr class AudioGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game sounds (everything non-bootstrap). + + See source for the full asset list. + """ achievement: SoundRef action_hero1: SoundRef @@ -439,7 +446,11 @@ if TYPE_CHECKING: zoe_scream01: SoundRef class MeshesGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game meshes (everything non-bootstrap). + + See source for the full asset list. + """ achievement_outline: MeshRef action_hero_fore_arm: MeshRef @@ -802,8 +813,197 @@ if TYPE_CHECKING: zoe_upper_arm: MeshRef zoe_upper_leg: MeshRef + class StringsEconomyGroup: + """ + Screen-messages about currency: grants and related notices. + + See source for the full asset list. + """ + + def you_got_tokens(self, *, tokens: int) -> LangStr: + """ + Confirmation effect sent to game clients when tokens are credited + (store purchases, promo codes, and other grant flows). + + English: (one) "You got # token!" / (other) "You got # tokens!" + """ + + class StringsGatherGroup: + """ + Party/gather UI strings: hosting-form labels, pre-join prompts, and + related networking-flow messages. + + See source for the full asset list. + """ + + #: Description line in the pre-join password prompt dialog, shown above + #: the password entry field when joining a password-protected party. + #: + #: English: "This party requires a password." + party_requires_password: LangStr + + #: Label for the optional party-password entry field in the gather + #: window's public-hosting form. + #: + #: English: "Password (optional)" + password_optional: LangStr + + class StringsInventoryGroup: + """ + Client-side inventory window bits: offline/signed-out placeholder + variants (the online inventory content itself is server-composed). + + See source for the full asset list. + """ + + #: Inventory placeholder message. + #: + #: English: "Full inventory is only available when online." + only_available_online: LangStr + + #: Inventory placeholder message. + #: + #: English: "Full inventory is only available when signed in." + only_available_signed_in: LangStr + + #: Window title (client-side offline/profiles-only variants; the online + #: inventory title comes from the server). + #: + #: English: "Inventory" + title: LangStr + + class StringsProfilesGroup: + """ + Player-profile management UI: profile lists, creation, and related + hints. + + See source for the full asset list. + """ + + #: Single-line parenthetical hint; keep the parentheses. + #: + #: English: "(custom player names and appearances for this account)" + explanation: LangStr + + #: Button label. + #: + #: English: "New Profile" + new_profile: LangStr + + #: Section heading / window title for player-profile management. + #: + #: English: "Player Profiles" + title: LangStr + + class StringsUiGroup: + """ + Generic UI vocabulary: short labels (buttons, dialog titles, joiners) + shared across many UIs. Purpose-specific wording belongs elsewhere - see + each entry's docs for what it is and is not. + + See source for the full asset list. + """ + + #: Abort button label; backs out of a dialog or in-progress action + #: without applying anything. NOT a "no" answer to a question and not + #: "back" navigation. + #: + #: English: "Cancel" + cancel: LangStr + + #: Confirmation label; commits a pending action (purchases and other + #: are-you-sure moments). Appears on commit buttons and as + #: confirm-dialog titles. Stronger than "ok" — implies something happens + #: as a result. + #: + #: English: "Confirm" + confirm: LangStr + + #: Completion button label; closes a screen or flow the user has + #: finished working in. Implies completed work — not a generic "close" + #: or "back". + #: + #: English: "Done" + done: LangStr + + #: Error-dialog title label; the dialog body carries the failure + #: details. Title only — never used as a full error message. + #: + #: English: "Error" + error: LangStr + + #: Generic error-page message. + #: + #: English: "An error has occurred." + error_occurred: LangStr + + #: Error-page message. + #: + #: English: "You must update the app to view this." + need_update: LangStr + + #: Placeholder label shown on an empty doc-ui section with no items to + #: display. + #: + #: English: "There is nothing here." + nothing_here: LangStr + + #: Generic affirmative/acknowledge button label; dismisses a dialog or + #: message with agreement. NOT a "yes" answer to a question (use a + #: dedicated yes/no pair for those). + #: + #: English: "Ok" + ok: LangStr + + def or_join(self, *, a: str | LangStr, b: str | LangStr) -> LangStr: + """ + Joiner between exactly two complete pre-rendered alternatives (e.g. + a price payable in either of two currencies: "500 tickets or 10 + tokens"). Not for lists of three or more and not a standalone "or" + word. + + English: "{a} or {b}" + """ + + #: Transient screen-message. + #: + #: English: "Page is refreshing - try again in a moment." + page_refreshing_try_again: LangStr + + #: Button label. + #: + #: English: "Retry" + retry: LangStr + + #: Error-page message; usually paired with a Retry button. + #: + #: English: "Error talking to server." + server_error: LangStr + + #: Error/placeholder-page message. + #: + #: English: "Under construction - check back soon." + under_construction: LangStr + + class StringsGroup: + """ + All standard game strings (everything non-bootstrap). + + See source for the full asset list. + """ + + economy: StringsEconomyGroup + gather: StringsGatherGroup + inventory: StringsInventoryGroup + profiles: StringsProfilesGroup + ui: StringsUiGroup + class TexturesGroup: - """Asset-group type; see source for the full list.""" + """ + All standard game textures (everything non-bootstrap). + + See source for the full asset list. + """ achievement_boxer: TextureRef achievement_cross_hair: TextureRef @@ -1129,6 +1329,10 @@ if TYPE_CHECKING: #: ``action_hero_lower_leg``, and 355 more). Full list in source. meshes: MeshesGroup + #: The ``strings`` group - 22 strings (``economy``, ``gather``, + #: ``inventory``, ``profiles``, ``ui``, and 17 more). Full list in source. + strings: StringsGroup + #: The ``textures`` group - 313 assets (``achievement_boxer``, #: ``achievement_cross_hair``, ``achievement_dual_wielding``, #: ``achievement_empty``, ``achievement_flawless_victory``, and 308 more). @@ -1912,6 +2116,31 @@ _TREE = { 'zoe_upper_arm': 'm', 'zoe_upper_leg': 'm', }, + 'strings': { + 'economy': {'you_got_tokens': ('tokens',)}, + 'gather': {'party_requires_password': (), 'password_optional': ()}, + 'inventory': { + 'only_available_online': (), + 'only_available_signed_in': (), + 'title': (), + }, + 'profiles': {'explanation': (), 'new_profile': (), 'title': ()}, + 'ui': { + 'cancel': (), + 'confirm': (), + 'done': (), + 'error': (), + 'error_occurred': (), + 'need_update': (), + 'nothing_here': (), + 'ok': (), + 'or_join': ('a', 'b'), + 'page_refreshing_try_again': (), + 'retry': (), + 'server_error': (), + 'under_construction': (), + }, + }, 'textures': { 'achievement_boxer': 't', 'achievement_cross_hair': 't', @@ -2233,4 +2462,5 @@ _TREE = { if not TYPE_CHECKING: audio = AssetRefDir(__asset_package__, _TREE['audio'], 'audio') meshes = AssetRefDir(__asset_package__, _TREE['meshes'], 'meshes') + strings = LangStrDir(__asset_package__, _TREE['strings'], 'strings') textures = AssetRefDir(__asset_package__, _TREE['textures'], 'textures') diff --git a/dist/ba_data/python/bauiv1lib/coop/browser.py b/dist/ba_data/python/bauiv1lib/coop/browser.py index cb07d9f..5dd840f 100644 --- a/dist/ba_data/python/bauiv1lib/coop/browser.py +++ b/dist/ba_data/python/bauiv1lib/coop/browser.py @@ -1062,7 +1062,7 @@ class CoopBrowserWindow(bui.MainWindow): ) -> None: """Run the provided game.""" # pylint: disable=cyclic-import - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.confirm import ConfirmWindow from bauiv1lib.account.signin import show_sign_in_prompt @@ -1108,7 +1108,7 @@ class CoopBrowserWindow(bui.MainWindow): on_connected=lambda: self.main_window_replace( bui.CallStrict( StoreUIController().create_window, - dui1.Request( + dui2.Request( '/', args={'unlockreqs': required_purchases}, ), @@ -1132,7 +1132,7 @@ class CoopBrowserWindow(bui.MainWindow): """Run the provided tournament game.""" # pylint: disable=too-many-return-statements - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.tournamententry import TournamentEntryWindow @@ -1220,7 +1220,7 @@ class CoopBrowserWindow(bui.MainWindow): on_connected=lambda: self.main_window_replace( bui.CallStrict( StoreUIController().create_window, - dui1.Request( + dui2.Request( '/', args={'unlockreqs': required_purchases}, ), diff --git a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py index 1193c62..72eed0a 100644 --- a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py @@ -29,7 +29,6 @@ class TournamentButton: select: bool, on_pressed: Callable[[TournamentButton], None], ) -> None: - # pylint: disable=too-many-positional-arguments self._r = 'coopSelectWindow' sclx = 300 scly = 195.0 diff --git a/dist/ba_data/python/bauiv1lib/docui/__init__.py b/dist/ba_data/python/bauiv1lib/docui/__init__.py index 189114b..cbdf268 100644 --- a/dist/ba_data/python/bauiv1lib/docui/__init__.py +++ b/dist/ba_data/python/bauiv1lib/docui/__init__.py @@ -1,5 +1,24 @@ # Released under the MIT License. See LICENSE for details. -"""Functionality for using doc-ui on top of bauiv1.""" +"""Functionality for using doc-ui on top of bauiv1. + +Threading design +================ + +Doc-ui deliberately offloads as much processing as possible to +background threads, keeping logic-thread work to the bare minimum +(instantiating widgets and running actions/effects). A request's whole +journey — controller fulfillment (including cloud/web round-trips), +response validation, asset-package resolution (marshalled to the logic +thread only for the async resolve await itself), l-string decode, and +full page prep — runs on an :attr:`~babase.App.threadpool` worker via +``DocUIController._process_request_in_bg``. Only the final prepped +page is pushed back to the logic thread for widget instantiation. + +Code called from that flow (controller ``fulfill_request`` overrides +especially) should preserve this: do the heavy lifting where you are +called (the bg thread) rather than pushing work to the logic thread, +and never assume logic-thread context without checking. +""" from bauiv1lib.docui._controller import DocUIController, DocUILocalAction from bauiv1lib.docui._window import DocUIWindow diff --git a/dist/ba_data/python/bauiv1lib/docui/_controller.py b/dist/ba_data/python/bauiv1lib/docui/_controller.py index cb6bd8e..6a7ec36 100644 --- a/dist/ba_data/python/bauiv1lib/docui/_controller.py +++ b/dist/ba_data/python/bauiv1lib/docui/_controller.py @@ -26,11 +26,12 @@ from bauiv1lib.docui._window import DocUIWindow if TYPE_CHECKING: from typing import Callable - import bacommon.docui.v1 + import bacommon.docui.v2 from bacommon.docui import DocUIRequest, DocUIResponse + from bacommon.langstr import LangStrSpec import bacommon.clienteffect as clfx - from bauiv1lib.docui import v1prep + from bauiv1lib.docui import prep class _WinState(Enum): @@ -107,12 +108,17 @@ class DocUIController: ) -> DocUIResponse: """Fulfill a request by sending it to a webserver.""" import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 import urllib3.util - if not isinstance(request, dui1.Request): + if not isinstance(request, (dui1.Request, dui2.Request)): raise RuntimeError(f'Unsupported docui request: {type(request)}') + # The v1 and v2 method enums share wire values; normalize to v1 + # for our http dispatch below. + method = dui1.RequestMethod(request.method.value) + upool = bui.app.net.urllib3pool # Allow compressed results. @@ -128,7 +134,7 @@ class DocUIController: try: # Map docui GET requests to http GET and POST to POST. - if request.method is dui1.RequestMethod.GET: + if method is dui1.RequestMethod.GET: # For GET we embed the request into a url param. raw_response = upool.request( 'GET', @@ -139,7 +145,7 @@ class DocUIController: headers=headers, ) - elif request.method is dui1.RequestMethod.POST: + elif method is dui1.RequestMethod.POST: # for POST we send the webrequest as json in body. headers['Content-Type'] = 'application/json' raw_response = upool.request( @@ -148,10 +154,10 @@ class DocUIController: headers=headers, body=dataclass_to_json(webrequest), ) - elif request.method is dui1.RequestMethod.UNKNOWN: + elif method is dui1.RequestMethod.UNKNOWN: raise RuntimeError('Unknown request method.') else: - assert_never(request.method) + assert_never(method) try: # We use 'lossy' here so response versions or elements @@ -201,6 +207,7 @@ class DocUIController: ) assert webresponse.doc_ui_response is not None + self._check_server_response(webresponse.doc_ui_response) return webresponse.doc_ui_response def fulfill_request_cloud( @@ -233,6 +240,7 @@ class DocUIController: ) assert isinstance(mresponse, bacommon.cloud.FulfillDocUIResponse) + self._check_server_response(mresponse.response) return mresponse.response except CommunicationError: @@ -243,6 +251,21 @@ class DocUIController: except Exception: return self.error_response(request) + @staticmethod + def _check_server_response(response: DocUIResponse) -> None: + """Run diagnostics on a pristine server-supplied response. + + Called at the receipt points (cloud/web fulfillment) — before + controllers splice in any local content — so finalization + checks only see what the server actually sent. + """ + import bacommon.docui.v2 as dui2 + + from bauiv1lib.docui import _resolve + + if isinstance(response, dui2.Response): + _resolve.check_finalization_leaks(response) + def error_response( self, request: DocUIRequest, @@ -254,35 +277,34 @@ class DocUIController: 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. + Messages are language-agnostic (bundled-package strings), so + error pages localize like any other doc-ui content; a + ``custom_message`` shows verbatim (untranslated). """ - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 + from bacommon.langstr import LangStrSpecValue - error_msg: bui.Lstr | None = None - error_msg_simple: str | None = None + from bauiv1 import stdassets - status_code = dui1.ResponseStatus.UNKNOWN_ERROR + uistrs = stdassets.strings.ui + + error_msg: LangStrSpec + + status_code = dui2.ResponseStatus.UNKNOWN_ERROR if custom_message is not None: - error_msg_simple = custom_message + error_msg = LangStrSpecValue(custom_message) + elif error_type is self.ErrorType.GENERIC: + error_msg = uistrs.error_occurred.spec + elif error_type is self.ErrorType.NEED_UPDATE: + error_msg = uistrs.need_update.spec + elif error_type is self.ErrorType.UNDER_CONSTRUCTION: + error_msg = uistrs.under_construction.spec + elif error_type is self.ErrorType.COMMUNICATION_ERROR: + status_code = dui2.ResponseStatus.COMMUNICATION_ERROR + error_msg = uistrs.server_error.spec 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 + assert_never(error_type) debug = False @@ -290,44 +312,37 @@ class DocUIController: # 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 + isinstance(request, dui2.Request) + and request.method is dui2.RequestMethod.GET + and status_code is dui2.ResponseStatus.COMMUNICATION_ERROR ) - return dui1.Response( + return dui2.Response( status=status_code, - page=dui1.Page( - title=bui.Lstr(resource='errorText').as_json(), - title_is_lstr=True, + page=dui2.Page( + title=uistrs.error.spec, center_vertically=True, rows=[ - dui1.ButtonRow( + dui2.ButtonRow( buttons=[ - dui1.Button( - bui.Lstr( - resource=( - 'retryText' if do_retry else 'okText' - ) - ).as_json(), - ( - dui1.Replace( - asserttype(request, dui1.Request) + dui2.Button( + (uistrs.retry if do_retry else uistrs.ok).spec, + action=( + dui2.Replace( + asserttype(request, dui2.Request) ) if do_retry - else dui1.Local(close_window=True) + else dui2.Local(close_window=True) ), - label_is_lstr=True, default=True, - style=dui1.ButtonStyle.MEDIUM, + style=dui2.ButtonStyle.MEDIUM, size=(130, 50), padding_left=200, padding_right=200, padding_top=100, decorations=[ - dui1.Text( - error_msg.as_json(), - is_lstr=True, + dui2.Text( + error_msg, position=(0, 80), size=(480, 50), highlight=False, @@ -420,7 +435,7 @@ class DocUIController: May immediately display old results or may kick off a new request. """ - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 assert bui.in_logic_thread() @@ -437,8 +452,8 @@ class DocUIController: # 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: + assert isinstance(win.request, dui2.Request) + if win.request.method is dui2.RequestMethod.POST: # Do we want a specific error for this? Though this case # should be rare I think. explicit_error = self.ErrorType.GENERIC @@ -480,7 +495,6 @@ class DocUIController: is_refresh: bool = False, ) -> None: """Kick off a request to replace existing window contents.""" - import bacommon.docui.v1 as dui1 import bacommon.docui.v2 as dui2 assert bui.in_logic_thread() @@ -489,11 +503,15 @@ class DocUIController: requesttype = request.get_type_id() - # V1 and V2 dispatch identically here; the v2 bg pass additionally - # resolves packages, decodes l-strings, and transcodes to v1. if requesttype is DocUIRequestTypeID.V1: - assert isinstance(win.request, dui1.Request) - self._submit_fresh_request(win, origin_widget, is_refresh) + # This client no longer works in v1. + bui.uilog.error('Got v1 doc-ui request; this is unsupported.') + self._submit_fresh_request( + win, + origin_widget, + is_refresh, + explicit_error=self.ErrorType.GENERIC, + ) elif requesttype is DocUIRequestTypeID.V2: assert isinstance(win.request, dui2.Request) self._submit_fresh_request(win, origin_widget, is_refresh) @@ -519,6 +537,13 @@ class DocUIController: explicit_error: DocUIController.ErrorType | None = None, ) -> None: """Lock the ui and kick off a fresh request's bg processing.""" + + # Timers (docui timed-actions especially) can still fire after + # the app threadpool is torn down; bow out quietly instead of + # erroring on submit once shutdown has begun. + if bui.app.shutting_down: + return + self._set_win_data( win, _WinData( @@ -550,14 +575,14 @@ class DocUIController: self, window: DocUIWindow, widgetid: str | None, - action: bacommon.docui.v1.Action | None, + action: bacommon.docui.v2.Action | None, is_timed: bool = False, ) -> None: - """Called when a button is pressed in a v1 ui.""" + """Called when a button is pressed in a doc-ui.""" # pylint: disable=too-many-branches # pylint: disable=cyclic-import - import bacommon.docui.v1 as dui + import bacommon.docui.v2 as dui assert bui.in_logic_thread() @@ -611,15 +636,6 @@ class DocUIController: ) ) - 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) @@ -633,15 +649,6 @@ class DocUIController: 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: @@ -747,8 +754,8 @@ class DocUIController: This will always return a response, even on error conditions. """ # pylint: disable=cyclic-import - import bacommon.docui.v1 as dui1 - from bauiv1lib.docui import v1prep + import bacommon.docui.v2 as dui2 + from bauiv1lib.docui import prep assert not bui.in_logic_thread() @@ -784,20 +791,14 @@ class DocUIController: 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 + # This client no longer works in v1 (servers serve v2 + # to any build with v2 support, so this implies either + # a server bug or a v1-only mod controller). + bui.uilog.error('Got v1 doc-ui response; this is unsupported.') + error = self.ErrorType.GENERIC + response = None elif responsetype is DocUIResponseTypeID.V2: - import bacommon.docui.v2 as dui2 - assert isinstance(response, dui2.Response) minbuild = response.minimum_engine_build if ( @@ -808,15 +809,15 @@ class DocUIController: response = None else: try: - # Resolve referenced packages, decode l-strings in - # our locale, and transcode to a v1 page so the - # existing v1 render pipeline draws it. - from bauiv1lib.docui import _v2transcode + # Resolve referenced packages in our locale and + # de-index deferred effects; the page then preps + # and renders natively. + from bauiv1lib.docui import _resolve - response = _v2transcode.resolve_and_transcode(response) + _resolve.resolve_response(response) except Exception: bui.uilog.exception( - 'Error rendering v2 doc-ui response.' + 'Error resolving v2 doc-ui response.' ) error = self.ErrorType.GENERIC response = None @@ -834,11 +835,12 @@ class DocUIController: 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) + # Currently must be v2 if it made it to here. + assert isinstance(response, dui2.Response) - pageprep = v1prep.prep_page( + pageprep = prep.prep_page( response.page, + packages=list(response.packages), uiscale=uiscale, scroll_width=scroll_width, scroll_height=scroll_height, @@ -865,9 +867,9 @@ class DocUIController: self, response: DocUIResponse, weakwin: weakref.ref[DocUIWindow], - pageprep: v1prep.PagePrep, + pageprep: prep.PagePrep, ) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 assert bui.in_logic_thread() @@ -877,13 +879,13 @@ class DocUIController: if win is None: return - # Currently should only be sending ourself v1 responses here. - assert isinstance(response, dui1.Response) + # Currently should only be sending ourself v2 responses here. + assert isinstance(response, dui2.Response) win.unlock_ui() win.set_last_response( response, - response.status == dui1.ResponseStatus.SUCCESS, + response.status == dui2.ResponseStatus.SUCCESS, ) # Set the UI. @@ -923,9 +925,9 @@ class DocUIController: # a refresh to swap in the latest version of the page; for POST # this is as far as we go (don't want to repeat POST effects). # (win.request stays the original v1-or-v2 request here.) - from bauiv1lib.docui import _v2transcode + from bauiv1lib.docui import _resolve - if _v2transcode.request_is_get(win.request): + if _resolve.request_is_get(win.request): self.replace(win, win.request, is_refresh=True) else: self._set_idle_and_schedule_timed_action(response, weakwin) @@ -943,12 +945,12 @@ class DocUIController: def _set_idle_and_schedule_timed_action( self, response: DocUIResponse, weakwin: weakref.ref[DocUIWindow] ) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 win = weakwin() assert win is not None assert self._get_win_data(win).state is not _WinState.IDLE - assert isinstance(response, dui1.Response) + assert isinstance(response, dui2.Response) refresh_timer: bui.AppTimer | None = None @@ -969,7 +971,7 @@ class DocUIController: def _run_timed_action( self, weakwin: weakref.ref[DocUIWindow], - action: bacommon.docui.v1.Action, + action: bacommon.docui.v2.Action, ) -> None: # If our target window died since we set this timer, no biggie. win = weakwin() diff --git a/dist/ba_data/python/bauiv1lib/docui/_resolve.py b/dist/ba_data/python/bauiv1lib/docui/_resolve.py new file mode 100644 index 0000000..b2b40b3 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/_resolve.py @@ -0,0 +1,285 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Pre-display resolution for native (v2) doc-ui responses. + +Before a v2 page renders, every asset-package its language-strings and +asset refs reference must be resolved locally in the current locale +(loading the packages' per-locale values into the native language +tables). Client-effects that may run later are also de-indexed to the +self-describing resource form here, while the response's package-index +map is still at hand. +""" + +from typing import TYPE_CHECKING + +import bacommon.docui.v2 as dui2 +from bacommon.langstr import LangStrSpec + +if TYPE_CHECKING: + from typing import Iterator + + from bacommon.locale import Locale + from bacommon.docui import DocUIRequest + from bacommon.assetref import TextureSpec, MeshSpec + + +def request_is_get(request: DocUIRequest) -> bool: + """Whether a doc-ui request uses the GET method.""" + import bacommon.docui.v1 as dui1 + + if isinstance(request, dui1.Request): + return request.method is dui1.RequestMethod.GET + if isinstance(request, dui2.Request): + return request.method is dui2.RequestMethod.GET + return False + + +def check_finalization_leaks(response: dui2.Response) -> None: + """Flag resource-form strings in a finalized server response. + + A response carrying a package manifest claims to be *fully* + indexed; any full-size (resource-form) value means some server + path skipped finalization. Call this on pristine server responses + only — controllers may legitimately splice local resource-form + content in afterward (offline rows etc.), so checking later would + misfire on that. (Decode is tolerant of mixed forms, so this is a + diagnostic, not a render gate. Local pages carry no manifest and + legitimately stay resource-form.) + """ + import bauiv1 as bui + import bacommon.clienteffect as clfx + from bacommon.langstr import contains_resource_form + + if not response.packages: + return + leaks = sum( + 1 + for lstr in page_langstrs(response.page) + if contains_resource_form(lstr) + ) + leaks += sum( + 1 + for effect in response.client_effects + if isinstance(effect, clfx.ScreenMessageV2) + and contains_resource_form(effect.message) + ) + if leaks: + bui.uilog.error( + 'Doc-ui response declares indexed language-strings but' + ' contains %d resource-form value(s); some server path' + ' is skipping finalization.', + leaks, + ) + + +def resolve_response(response: dui2.Response) -> None: + """Resolve packages + de-index deferred effects for a v2 response. + + Runs in a background thread (the resolve itself is marshalled to + the logic thread and awaited). After this returns, every package + the page references is locally resolved in the current locale and + the response's client-effects carry self-describing language + strings, so the page can be prepped and rendered natively. + """ + import bauiv1 as bui + + assert not bui.in_logic_thread() + + import bacommon.clienteffect as clfx + + # Sanity check: responses can be tailored per-build (client-effect + # forms etc.), so one stamped for a different build is stale — note + # it loudly. (When response caching arrives this should become a + # toss-and-refetch.) + ourbuild = bui.app.env.engine_build_number + if response.for_build is not None and response.for_build != ourbuild: + bui.uilog.warning( + 'Got doc-ui response built for engine build %d but we are' + ' build %d; it may contain stale/mismatched content.', + response.for_build, + ourbuild, + ) + + locale = bui.app.locale.current_locale + + # A wire response finalized to the indexed form carries its package + # manifest; that plus the walk below (asset refs, plus any + # resource-form strings on local/legacy pages) covers everything we + # need resolved before render — including packages the contained + # client-effects will want later. + apverids: set[str] = set(response.packages) + collect_apverids(response.page, apverids) + clfx.collect_apverids(response.client_effects, apverids) + + bui.uilog.debug( + 'docui v2 prep: resolving %d package(s) for locale %s: %s.', + len(apverids), + locale.name, + sorted(apverids), + ) + _resolve_packages_blocking(sorted(apverids), locale) + bui.uilog.debug( + 'docui v2 prep: resolve complete for locale %s.', locale.name + ) + + # Native handles bound against this payload's package manifest; + # evaluation and de-indexing both resolve through the native + # language tables the resolve just (re)loaded. + import babase + from efro.dataclassio import dataclass_to_json, dataclass_from_json + + packages = list(response.packages) + + def _native(lstr: LangStrSpec) -> babase.LangStr: + return babase.LangStr(dataclass_to_json(lstr), packages=packages) + + # Client-effects run later (deferred; possibly after this + # response and its package-index map are gone), so convert their + # indexed strings back to the self-describing resource form the + # effects runner consumes. Fail-soft per effect: an unconvertible + # message is left as-is and fails visibly at run time instead. + def _deindex_effects(effects: 'list[clfx.Effect]') -> None: + for effect in effects: + if isinstance(effect, clfx.ScreenMessageV2): + try: + effect.message = dataclass_from_json( + LangStrSpec, _native(effect.message).to_resource_json() + ) + except Exception: + bui.uilog.exception( + 'Error de-indexing client-effect message.' + ) + + if response.packages: + _deindex_effects(response.client_effects) + for row in response.page.rows: + if not isinstance(row, dui2.ButtonRow): + continue + for button in row.buttons: + if isinstance(button.action, dui2.Local): + _deindex_effects(button.action.immediate_client_effects) + + +def _resolve_packages_blocking(apverids: list[str], locale: Locale) -> None: + """Run the async, logic-thread asset resolve and block until done. + + Called from the background prep thread; marshals the resolve onto the + logic thread (where it must run) and waits on it. + """ + import threading + + import bauiv1 as bui + + if not apverids: + return + + done = threading.Event() + box: dict[str, BaseException] = {} + + def _kick() -> None: + async def _run() -> None: + try: + await bui.app.assets.resolve(apverids, language=locale) + except Exception as exc: + box['error'] = exc + finally: + done.set() + + bui.app.create_async_task(_run()) + + bui.pushcall(_kick, from_other_thread=True) + if not done.wait(timeout=30.0): + raise RuntimeError('Timed out resolving doc-ui asset-packages.') + if 'error' in box: + raise box['error'] + + +def collect_apverids(page: dui2.Page, acc: set[str]) -> None: + """Gather every asset-package-version the page's l-strings reference.""" + import bacommon.clienteffect as clfx + from bacommon import langstr + + # (The recursive langstr walk lives at module level in + # bacommon.langstr; a self-recursive closure here would create a + # reference cycle per call.) + def _walk(lstr: LangStrSpec) -> None: + langstr.collect_apverids(lstr, acc) + + def _maybe(lstr: LangStrSpec | None) -> None: + if lstr is not None: + _walk(lstr) + + def _ref(ref: TextureSpec | MeshSpec | None) -> None: + if ref is not None: + acc.add(ref.apverid) + + def _decos(decos: list[dui2.Decoration] | None) -> None: + for deco in decos or []: + if isinstance(deco, dui2.Text): + _walk(deco.text) + elif isinstance(deco, dui2.Image): + _ref(deco.texture) + _ref(deco.tint_texture) + _ref(deco.mask_texture) + _ref(deco.mesh_opaque) + _ref(deco.mesh_transparent) + + _walk(page.title) + for row in page.rows: + if not isinstance(row, dui2.ButtonRow): + continue + _maybe(row.title) + _maybe(row.subtitle) + _decos(row.header_decorations_left) + _decos(row.header_decorations_center) + _decos(row.header_decorations_right) + for button in row.buttons: + _maybe(button.label) + _ref(button.texture) + _ref(button.icon) + _decos(button.decorations) + # Button-press effects (v2 forms) reference packages too; + # gathering them here pre-warms them during page resolve so + # press-time runs are cache hits. + if isinstance(button.action, dui2.Local): + clfx.collect_apverids( + button.action.immediate_client_effects, acc + ) + + +def page_langstrs(page: dui2.Page) -> 'Iterator[LangStrSpec]': + """Yield every top-level language-string slot in a page. + + Covers titles/subtitles/labels/text decorations plus messages in + button immediate-client-effects (nested substitution values are + *not* yielded separately; walk each yielded tree if you need + those). + """ + import bacommon.clienteffect as clfx + + def _decos( + decos: list[dui2.Decoration] | None, + ) -> 'Iterator[LangStrSpec]': + for deco in decos or []: + if isinstance(deco, dui2.Text): + yield deco.text + + yield page.title + for row in page.rows: + if not isinstance(row, dui2.ButtonRow): + continue + if row.title is not None: + yield row.title + if row.subtitle is not None: + yield row.subtitle + yield from _decos(row.header_decorations_left) + yield from _decos(row.header_decorations_center) + yield from _decos(row.header_decorations_right) + for button in row.buttons: + if button.label is not None: + yield button.label + yield from _decos(button.decorations) + if isinstance(button.action, dui2.Local): + for effect in button.action.immediate_client_effects: + if isinstance(effect, clfx.ScreenMessageV2): + yield effect.message diff --git a/dist/ba_data/python/bauiv1lib/docui/_window.py b/dist/ba_data/python/bauiv1lib/docui/_window.py index 4c4d858..6d82081 100644 --- a/dist/ba_data/python/bauiv1lib/docui/_window.py +++ b/dist/ba_data/python/bauiv1lib/docui/_window.py @@ -13,9 +13,8 @@ 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 + from bauiv1lib.docui import prep class DocUIWindow(bui.MainWindow): @@ -307,18 +306,17 @@ class DocUIWindow(bui.MainWindow): 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.V2: import bacommon.docui.v2 as dui2 + # One state per path seems like a reasonable default. assert isinstance(request, dui2.Request) return request.path - if requesttypeid is DocUIRequestTypeID.UNKNOWN: + if ( + requesttypeid is DocUIRequestTypeID.V1 + or requesttypeid is DocUIRequestTypeID.UNKNOWN + ): + # The client no longer works in v1; treat like unknown. return 'unknown' assert_never(requesttypeid) @@ -393,36 +391,33 @@ class DocUIWindow(bui.MainWindow): # 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.V2: + if responsetypeid is DocUIResponseTypeID.V2: import bacommon.docui.v2 as dui2 assert isinstance(response, dui2.Response) self._last_response_shared_state_id = response.shared_state_id - elif responsetypeid is DocUIResponseTypeID.UNKNOWN: + elif ( + responsetypeid is DocUIResponseTypeID.V1 + or responsetypeid is DocUIResponseTypeID.UNKNOWN + ): + # The client no longer works in v1; treat like unknown. self._last_response_shared_state_id = None else: assert_never(responsetypeid) - def instantiate_ui(self, pageprep: v1prep.PagePrep) -> None: + def instantiate_ui(self, pageprep: prep.PagePrep) -> None: """Replace any current ui with provided prepped one. :meta private: """ - from bauiv1lib.docui.v1prep._calls import ( - doc_ui_v1_instantiate_page_prep, - ) + from bauiv1lib.docui.prep._calls import instantiate_page_prep assert bui.in_logic_thread() - # Set title. + # Set title (a native language-string handle). bui.textwidget( edit=self._title, - literal=not pageprep.title_is_lstr, + literal=True, text=pageprep.title, ) @@ -439,7 +434,7 @@ class DocUIWindow(bui.MainWindow): simple_culling_v=pageprep.simple_culling_v, center_small_content=(pageprep.center_vertically), ) - self._subcontainer = doc_ui_v1_instantiate_page_prep( + self._subcontainer = instantiate_page_prep( pageprep, rootwidget=self._root_widget, scrollwidget=self._scrollwidget, diff --git a/dist/ba_data/python/bauiv1lib/docui/prep/__init__.py b/dist/ba_data/python/bauiv1lib/docui/prep/__init__.py new file mode 100644 index 0000000..5f5913b --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/prep/__init__.py @@ -0,0 +1,45 @@ +# Released under the MIT License. See LICENSE for details. +"""Functionality related to prepping a doc-ui page for display. + +Consumes native (v2 / language-agnostic) doc-ui documents: text rides +as :class:`bacommon.langstr.LangStrSpec` (handed to widgets as native +handles that re-evaluate on language changes) and assets as typed refs. + +.. warning:: + + This is an internal api and subject to change at any time. Do not use + it in mod code. +""" + +from bauiv1lib.docui.prep._types import ( + DecorationPrep, + ButtonPrep, + RowPrep, + PagePrep, +) +from bauiv1lib.docui.prep._calls import prep_page, instantiate_page_prep +from bauiv1lib.docui.prep._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', + 'instantiate_page_prep', + '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/prep/_calls.py b/dist/ba_data/python/bauiv1lib/docui/prep/_calls.py new file mode 100644 index 0000000..3e5ff88 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/prep/_calls.py @@ -0,0 +1,794 @@ +# 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. +""" + +import copy +from functools import partial +from typing import TYPE_CHECKING, assert_never + +from efro.util import strict_partial +from efro.dataclassio import dataclass_to_json +import bacommon.docui.v2 as dui2 +import bauiv1 as bui +from bauiv1 import builtinassets +from bauiv1 import stdassets + +from bauiv1lib.docui.prep._types import PagePrep, RowPrep, ButtonPrep + +if TYPE_CHECKING: + from typing import Callable + + from bacommon.langstr import LangStrSpec + from bacommon.assetref import TextureSpec, MeshSpec + from bauiv1lib.docui import DocUIWindow + + +def _btex(name: str) -> str: + """Qualified ref for a texture in the builtin asset-package.""" + return f'{builtinassets.__asset_package__}:textures/{name}' + + +def refstr(ref: 'TextureSpec | MeshSpec') -> str: + """Qualified engine name for a typed asset ref.""" + return f'{ref.apverid}:{ref.name}' + + +def prep_page( + page: dui2.Page, + *, + packages: list[str], + uiscale: bui.UIScale, + scroll_width: float, + scroll_height: float, + idprefix: str, + immediate: bool = False, +) -> PagePrep: + # pylint: disable=too-many-statements + """Prep a page.""" + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + + import bauiv1lib.docui.prep._calls2 as prepcalls2 + + def _n(lstr: 'LangStrSpec') -> bui.LangStr: + """Native handle bound against this payload's package list.""" + return bui.LangStr(dataclass_to_json(lstr), packages=packages) + + # Create a filtered list of rows we know how to display. + page_rows_filtered: list[dui2.ButtonRow] = [] + for pagerow in page.rows: + if isinstance(pagerow, dui2.ButtonRow): + if not pagerow.buttons: + pagerow = copy.deepcopy(pagerow) + pagerow.buttons.append( + dui2.Button( + label=stdassets.strings.ui.nothing_here.spec, + label_color=(1, 1, 1, 0.3), + size=(220, 100), + label_scale=0.6, + texture=builtinassets.textures.button_square_wide, + padding_top=-8, + padding_bottom=-10, + color=(0.2, 0.2, 0.2, 0.15), + action=dui2.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: bui.LangStr = _n(page.title) + + # 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.15 + 0.06 * i + + 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), + packages=packages, + 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), + packages=packages, + 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), + packages=packages, + 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=_n(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=True, + transition_delay=( + None if immediate else (tdelaybase + 0.1) + ), + transition_type='scale', + ) + ) + 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=_n(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=True, + transition_delay=( + None if immediate else (tdelaybase + 0.2) + ), + transition_type='scale', + ) + ) + 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): + # Leftmost buttons appear first; pop-in sweeps left-to-right. + tdelayamt = j / max(1, bcount - 1) + 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 dui2.ButtonStyle.SQUARE: + bstyle = 'square' + elif button.style is dui2.ButtonStyle.TAB: + bstyle = 'tab' + elif button.style is dui2.ButtonStyle.SMALL: + bstyle = 'small' + elif button.style is dui2.ButtonStyle.MEDIUM: + bstyle = 'medium' + elif button.style is dui2.ButtonStyle.LARGE: + bstyle = 'large' + elif button.style is dui2.ButtonStyle.LARGER: + bstyle = 'larger' + elif button.style is dui2.ButtonStyle.BACK: + bstyle = 'back' + elif button.style is dui2.ButtonStyle.BACK_SMALL: + bstyle = 'backSmall' + elif button.style is dui2.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 _n(button.label)), + text_literal=True, + autoselect=True, + enable_sound=False, + transition_delay=None if immediate else tdelay, + transition_type='scale', + 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'] = refstr(button.texture) + + if button.icon is not None: + buttonprep.textures['icon'] = refstr(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, + packages=packages, + 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, + root_post_calls=root_post_calls, + ) + + +def 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/prep/_calls2.py b/dist/ba_data/python/bauiv1lib/docui/prep/_calls2.py new file mode 100644 index 0000000..3de329f --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/prep/_calls2.py @@ -0,0 +1,640 @@ +# 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 functools import partial +from typing import TYPE_CHECKING, assert_never + +from efro.util import pairs_from_flat +from efro.dataclassio import dataclass_to_json +import bacommon.displayitem as ditm +import bacommon.docui.v2 as dui2 +import bauiv1 as bui +from bauiv1 import builtinassets +from bauiv1 import stdassets + +from bauiv1lib.docui.prep._types import DecorationPrep + +if TYPE_CHECKING: + from typing import Any, Callable + + from bacommon.langstr import LangStrSpec + from bauiv1lib.docui import DocUIWindow + + +def _native(lstr: 'LangStrSpec', packages: list[str]) -> bui.LangStr: + """Native handle bound against a payload's package list.""" + return bui.LangStr(dataclass_to_json(lstr), packages=packages) + + +def _btex(name: str) -> str: + """Qualified ref for a texture in the builtin asset-package.""" + return f'{builtinassets.__asset_package__}:textures/{name}' + + +def _stex(name: str) -> str: + """Qualified stdassets texture ref.""" + return f'{stdassets.__asset_package__}:textures/{name}' + + +def _refstr(ref: 'Any') -> str: + """Qualified engine name for a typed asset ref.""" + return f'{ref.apverid}:{ref.name}' + + +def prep_decorations( + decorations: list[dui2.Decoration], + center_x: float, + center_y: float, + scale: float, + tdelay: float | None, + *, + packages: list[str], + 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 dui2.DecorationTypeID.UNKNOWN: + if bui.do_once(): + bui.uilog.exception( + 'DocUI receieved unknown decoration;' + ' this is likely a server error.' + ) + elif dectypeid is dui2.DecorationTypeID.TEXT: + assert isinstance(decoration, dui2.Text) + prep_text( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + packages=packages, + highlight=highlight, + ) + + elif dectypeid is dui2.DecorationTypeID.IMAGE: + assert isinstance(decoration, dui2.Image) + prep_image( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + highlight=highlight, + ) + elif dectypeid is dui2.DecorationTypeID.DISPLAY_ITEM: + assert isinstance(decoration, dui2.DisplayItem) + prep_display_item( + decoration, + (center_x, center_y), + scale, + tdelay, + out_decoration_preps, + highlight=highlight, + ) + else: + assert_never(dectypeid) + + +def prep_text( + text: dui2.Text, + bcenter: tuple[float, float], + bscale: float, + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], + *, + packages: list[str], + 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 dui2.HAlign.LEFT: + h_align = 'left' + elif text.h_align is dui2.HAlign.CENTER: + h_align = 'center' + elif text.h_align is dui2.HAlign.RIGHT: + h_align = 'right' + else: + assert_never(text.h_align) + + if text.v_align is dui2.VAlign.TOP: + v_align = 'top' + elif text.v_align is dui2.VAlign.CENTER: + v_align = 'center' + elif text.v_align is dui2.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=_native(text.text, packages), + literal=True, + transition_delay=tdelay, + transition_type='scale', + 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 dui2.HAlign.LEFT: + mwxoffs = xoffs + elif text.h_align is dui2.HAlign.CENTER: + mwxoffs = xoffs - mwfull * 0.5 + elif text.h_align is dui2.HAlign.RIGHT: + mwxoffs = xoffs - mwfull + else: + assert_never(text.h_align) + + if text.v_align is dui2.VAlign.TOP: + mwyoffs = yoffs - mhfull + elif text.v_align is dui2.VAlign.CENTER: + mwyoffs = yoffs - mhfull * 0.5 + elif text.v_align is dui2.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, + transition_type='scale', + ), + textures={'texture': _btex('white')}, + meshes={}, + highlight=True, + ) + ) + + +def prep_image( + image: dui2.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 dui2.HAlign.LEFT: + xoffsfin = xoffs + elif image.h_align is dui2.HAlign.CENTER: + xoffsfin = xoffs - widthfull * 0.5 + elif image.h_align is dui2.HAlign.RIGHT: + xoffsfin = xoffs - widthfull + else: + assert_never(image.h_align) + + if image.v_align is dui2.VAlign.TOP: + yoffsfin = yoffs - heightfull + elif image.v_align is dui2.VAlign.CENTER: + yoffsfin = yoffs - heightfull * 0.5 + elif image.v_align is dui2.VAlign.BOTTOM: + yoffsfin = yoffs + else: + assert_never(image.v_align) + + textures: dict[str, str] = {'texture': _refstr(image.texture)} + if image.tint_texture is not None: + textures['tint_texture'] = _refstr(image.tint_texture) + if image.mask_texture is not None: + textures['mask_texture'] = _refstr(image.mask_texture) + + meshes: dict[str, str] = {} + if image.mesh_opaque is not None: + meshes['mesh_opaque'] = _refstr(image.mesh_opaque) + if image.mesh_transparent is not None: + meshes['mesh_transparent'] = _refstr(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, + transition_type='scale', + 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': _btex('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, + transition_type='scale', + ), + 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': _btex('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, + transition_type='scale', + ), + 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': _btex('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, + transition_type='scale', + ), + textures=textures, + meshes={}, + highlight=True, + ) + ) + + +def prep_display_item( + display_item: dui2.DisplayItem, + parent_center: tuple[float, float], + parent_scale: float, + tdelay: float | None, + out_decoration_preps: list[DecorationPrep], + *, + highlight: bool, +) -> None: + # pylint: disable=too-many-statements + """Prep decorations for a display-item.""" + # pylint: disable=too-many-branches + # 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, + transition_type='scale', + ), + textures={'texture': _btex('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 dui2.DisplayItemStyle.FULL: + aspect_ratio = 0.75 # Bit less tall than wide (graphic centric). + compact = False + icon = False + elif display_item.style is dui2.DisplayItemStyle.COMPACT: + aspect_ratio = 0.5 # Significantly wider (text centric) + compact = True + icon = False + elif display_item.style is dui2.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, + transition_type='scale', + ), + textures={'texture': _btex('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, + transition_type='scale', + 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 = _stex('coin') + if compact: + text = str(item.count) + elif itemtype is ditm.ItemTypeID.TICKETS: + assert isinstance(item, ditm.Tickets) + img = _stex('tickets') + if compact: + text = str(item.count) + elif itemtype is ditm.ItemTypeID.TICKETS_PURPLE: + assert isinstance(item, ditm.PurpleTickets) + img = _stex('tickets_purple') + 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, + transition_type='scale', + 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, + transition_type='scale', + depth_range=display_item.depth_range, + ), + textures={}, + meshes={}, + highlight=highlight and display_item.highlight, + ) + ) diff --git a/dist/ba_data/python/bauiv1lib/docui/prep/_types.py b/dist/ba_data/python/bauiv1lib/docui/prep/_types.py new file mode 100644 index 0000000..165cb6f --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/docui/prep/_types.py @@ -0,0 +1,71 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Types used in prepping a doc-ui page for display. + +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 dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + + import bacommon.docui.v2 + import bauiv1 + + from bauiv1lib.docui._window import DocUIWindow + + +@dataclass +class DecorationPrep: + """Prep for a decoration in a 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 doc-ui.""" + + buttoncall: Callable[..., bauiv1.Widget] + buttoneditcall: Callable | None + decorations: list[DecorationPrep] + textures: dict[str, str] + widgetid: str + action: bacommon.docui.v2.Action | None + + +@dataclass +class RowPrep: + """Prep for a row in a 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 doc-ui.""" + + rootcall: Callable[..., bauiv1.Widget] | None + rows: list[RowPrep] + width: float + height: float + simple_culling_v: float + center_vertically: bool + #: Native language-string title handle. + title: bauiv1.LangStr + 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 index 850ed2d..c77f143 100644 --- a/dist/ba_data/python/bauiv1lib/docuitest.py +++ b/dist/ba_data/python/bauiv1lib/docuitest.py @@ -2,8 +2,6 @@ # """Examples/tests for using DocUI to build UIs.""" -# pylint: disable=too-many-lines - import time import copy from typing import TYPE_CHECKING, override @@ -17,8 +15,8 @@ from bauiv1lib.docui import DocUIWindow, DocUIController if TYPE_CHECKING: from bacommon.docui import DocUIRequest, DocUIResponse - import bacommon.docui.v1 import bacommon.docui.v2 + from bacommon.langstr import LangStrSpec from bauiv1lib.docui import DocUILocalAction @@ -33,29 +31,16 @@ def _stex(name: str) -> str: return f'{stdassets.__asset_package__}:textures/{name}' -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(), - ) - - def show_test_doc_ui_v2_window() -> None: - """Bust out a v2 (l-string) doc-ui window built locally on the client. + """Bust out a doc-ui test window built locally on the client. - Unlike the cloud-fetched milestone-1 demo, this authors a - language-agnostic v2 page *on the client* using the bauiv1 asset-package - wrappers (``bamvpstrings`` / ``bamvpstrings2``), proving that doc-ui-v2 - documents can be created locally. A 'Server Version' button fetches the - equivalent page from bamaster's ``docuitestv2`` domain so the full - cloud resolve -> decode -> render path stays exercised too. + Test pages authored as language-agnostic v2 documents — text as + ``LangStrSpec`` values from the ``badocuiv2testassets`` package (decoded + in the client's locale at render time), textures/meshes as typed + refs, and multi-line labels via wrap-params instead of hand-baked + newlines. The Cloud-Msg and Web buttons fetch equivalent v2 pages + from bamaster, keeping the full cloud/web resolve -> decode -> + render paths exercised. """ import bacommon.docui.v2 as dui2 @@ -69,133 +54,36 @@ def show_test_doc_ui_v2_window() -> None: class TestDocUIV2Controller(DocUIController): - """Builds a v2 (l-string) doc-ui page locally on the client. + """Tests/demonstrations of native (v2 / l-string) docui. - The root page is authored client-side; the ``/server`` path fetches the - equivalent page from bamaster so both paths get exercised. + Local pages are authored client-side; the ``/cloudmsgtest/*`` and + ``/webtest/*`` paths fetch equivalent v2 pages from bamaster. """ @override def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: """Fulfill a v2 request (called in a background thread).""" + # pylint: disable=too-many-return-statements import bacommon.docui.v2 as dui2 if not isinstance(request, dui2.Request): raise CleanError('Invalid request version.') - # The root page is built right here on the client. - if request.path == '/': - return _test_v2_page_root() - - # The '/server' path fetches the equivalent page from bamaster's - # 'docuitestv2' domain (the cloud-authored counterpart). - if request.path == '/server': - return self.fulfill_request_cloud(request, 'docuitestv2') - - raise CleanError('Invalid request path.') - - -def _test_v2_page_root() -> bacommon.docui.v2.Response: - """Author the v2 (l-string) test page purely on the client. - - All text is authored as ``Lstr`` from the bamvpstrings test package and - all textures/meshes as apverid-pinned references from bamvpstrings2 -- the - same content bamaster's ``docuitestv2`` domain produces, but built locally - here. The client resolves those packages in its own locale and decodes, so - this single response renders in any language. - """ - import bacommon.docui.v2 as dui2 - - from bauiv1.bamvpstrings import strings as mvp - from bauiv1.bamvpstrings2 import textures as mvptex, meshes as mvpmesh - - return dui2.Response( - page=dui2.Page( - title=mvp.strings.mvpgreeting, - center_vertically=True, - rows=[ - dui2.ButtonRow( - title=mvp.strings.mvphello(player='Bo'), - subtitle=mvp.strings.mvpcolor, - center_content=True, - buttons=[ - dui2.Button( - label=mvp.strings.mvpfarewell, - action=dui2.Local(close_window=True), - size=(180, 200), - style=dui2.ButtonStyle.MEDIUM, - default=True, - selected=True, - decorations=[ - # NOTE: decorations must sit within the button - # bounds (here +-90 x, +-100 y) or they get - # culled. - # - # An apverid-pinned texture + mesh, authored - # type-safely from the client wrappers - # (resolved + drawn after the resolve phase). - # mesh_transparent (not _opaque): UI images - # draw in the transparent pass. - dui2.Image( - texture=mvptex.v2tex, - mesh_transparent=mvpmesh.v2diamond, - position=(0, 45), - size=(90, 90), - ), - dui2.Text( - text=mvp.strings.mvpbananacount(bananas=5), - position=(0, -70), - size=(160, 30), - scale=0.7, - ), - ], - ), - dui2.Button( - label=mvp.strings.mvpgreeting, - action=dui2.Browse(dui2.Request('/server')), - size=(160, 80), - style=dui2.ButtonStyle.MEDIUM, - texture=mvptex.v2tex, - ), - ], - ), - ], - ) - ) - - -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) + return _test_v2_page_root(request) if request.path == '/test2': - return _test_page_2(request) + return _test_v2_page_2(request) if request.path == '/slow': - return _test_page_long(request) + return _test_v2_page_long(request) if request.path == '/timedactions': - return _test_page_timed_actions(request) + return _test_v2_page_timed_actions(request) if request.path == '/displayitems': - return _test_page_display_items(request) + return _test_v2_page_display_items(request) if request.path == '/emptypage': - return _test_page_empty(request) + return _test_v2_page_empty(request) if request.path == '/boundstests': - return _test_bounds(request) + return _test_v2_bounds(request) # Ship '/webtest/*' off to some webserver to handle. if request.path.startswith('/webtest/'): @@ -205,7 +93,7 @@ class TestDocUIController(DocUIController): # Ship '/cloudmsgtest/*' through our cloud connection to handle. if request.path.startswith('/cloudmsgtest/'): - return self.fulfill_request_cloud(request, 'test') + return self.fulfill_request_cloud(request, 'docuitestv2') raise CleanError('Invalid request path.') @@ -216,31 +104,417 @@ class TestDocUIController(DocUIController): ) -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 +def _test_v2_page_root( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """Author the v2 (l-string) test root page purely on the client. - del request # Unused. + The full v1 test root page, with all text authored as + language-agnostic ``LangStrSpec`` values from the ``badocuiv2testassets`` + package, textures/meshes as typed refs from + ``builtinassets``/``stdassets``, and multi-line labels wrapped via + definition-time :class:`~bacommon.langstr.WrapParams` on the + package's string definitions (decision D-t) instead of v1's + hand-baked newlines. The client resolves the referenced packages + in its own locale, decodes, and wraps -- so this single response + renders in any language. + """ + import bacommon.clienteffect as clfx + import bacommon.docui.v2 as dui2 - # Simulate a slow connection or whatnot. - time.sleep(3.0) + from bauiv1 import _docuiv2testassets - return dui1.Response( - page=dui1.Page( - title='Test', - center_vertically=True, + strs = _docuiv2testassets.strings + + # Show some specific debug bits if they ask us to. + debug = bool(request.args.get('debug', False)) + + response = dui2.Response( + page=dui2.Page( + title=strs.nav.test_root_title.spec, rows=[ - dui1.ButtonRow( - title='That took a while', - center_title=True, - center_content=True, + dui2.ButtonRow( + debug=debug, + header_height=100, + header_decorations_left=[ + dui2.Text( + text=strs.common.header_left.spec, + position=(0, 10 + 20), + color=(1, 1, 1, 0.3), + size=(150, 30), + h_align=dui2.HAlign.LEFT, + debug=debug, + ), + ], + header_decorations_center=[ + dui2.Text( + text=strs.common.hello_from_docui.spec, + position=(0, 10 + 20), + size=(300, 30), + debug=debug, + ), + dui2.Text( + text=strs.common.docui_reference.spec, + scale=0.5, + position=(0, -18 + 20), + size=(600, 23), + debug=debug, + ), + dui2.Image( + texture=builtinassets.textures.nub, + position=(0, -58 + 20), + size=(60, 60), + ), + ], + header_decorations_right=[ + dui2.Text( + text=strs.common.header_right.spec, + position=(0, 10 + 20), + color=(1, 1, 1, 0.3), + size=(150, 30), + h_align=dui2.HAlign.RIGHT, + debug=debug, + ), + ], + title=strs.nav.some_tests.spec, buttons=[ - dui1.Button( - 'Sure Did', + dui2.Button( + label=strs.nav.browse.spec, size=(120, 80), - action=dui1.Browse(dui1.Request('/')), + action=dui2.Browse(dui2.Request('/test2')), + ), + dui2.Button( + label=strs.nav.replace.spec, + size=(120, 80), + action=dui2.Replace(dui2.Request('/test2')), + ), + dui2.Button( + label=strs.nav.close.spec, + size=(120, 80), + action=dui2.Local(close_window=True), + ), + dui2.Button( + label=strs.common.invalid_request.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/invalidrequest')), + ), + dui2.Button( + label=strs.effects.immediate_client_effects.spec, + size=(120, 80), + action=dui2.Local( + # V2 effect forms: l-string text decoded + # in the client's locale + a typed sound + # ref from an asset package. The page + # resolve pre-warms the referenced + # packages, so press-time runs are + # cache hits. + immediate_client_effects=[ + clfx.ScreenMessageV2( + message=( + strs.effects.immediate_effects_hello + ).spec, + color=(0, 1, 0), + ), + clfx.PlaySoundV2( + sound=( + builtinassets.audio + ).cash_register, + ), + clfx.Delay(1.0), + clfx.ScreenMessageV2( + message=( + strs.effects.effect_success + ).spec, + color=(0, 1, 0), + ), + clfx.PlaySoundV2( + sound=( + builtinassets.audio + ).cash_register, + ), + ] + ), + ), + dui2.Button( + label=strs.effects.response_client_effects.spec, + size=(120, 80), + action=dui2.Browse( + dui2.Request('/', args={'test_effects': True}) + ), + ), + dui2.Button( + label=strs.effects.immediate_local_action.spec, + size=(120, 80), + action=dui2.Local( + immediate_local_action='testaction', + immediate_local_action_args={'testparam': 123}, + ), + ), + dui2.Button( + label=strs.effects.response_local_action.spec, + size=(120, 80), + action=dui2.Browse( + dui2.Request('/', args={'test_action': True}) + ), + ), + ], + ), + dui2.ButtonRow( + title=strs.nav.few_more_tests.spec, + buttons=[ + dui2.Button( + label=( + strs.common.hide_debug.spec + if debug + else strs.common.show_debug.spec + ), + size=(120, 80), + action=dui2.Replace( + dui2.Request('/', args={'debug': not debug}) + ), + ), + dui2.Button( + label=strs.nav.slow_browse.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/slow')), + ), + dui2.Button( + label=strs.nav.slow_replace.spec, + size=(120, 80), + action=dui2.Replace(dui2.Request('/slow')), + ), + dui2.Button( + label=strs.common.timed_actions.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/timedactions')), + ), + dui2.Button( + label=strs.web.web_get.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/webtest/get')), + ), + dui2.Button( + label=strs.web.web_post.spec, + size=(120, 80), + action=dui2.Browse( + dui2.Request( + '/webtest/post', + method=dui2.RequestMethod.POST, + ) + ), + ), + dui2.Button( + label=strs.items.display_items.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/displayitems')), + ), + dui2.Button( + label=strs.layout.empty_page.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/emptypage')), + ), + ], + ), + dui2.ButtonRow( + title=strs.nav.even_more_tests.spec, + buttons=[ + dui2.Button( + label=strs.cloud.cloud_msg_get.spec, + size=(120, 80), + action=dui2.Browse( + dui2.Request('/cloudmsgtest/get') + ), + ), + dui2.Button( + label=strs.cloud.cloud_msg_post.spec, + size=(120, 80), + action=dui2.Browse( + dui2.Request( + '/cloudmsgtest/post', + method=dui2.RequestMethod.POST, + ) + ), + ), + dui2.Button( + label=strs.layout.bounds_tests.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/boundstests')), + ), + ], + ), + dui2.ButtonRow(title=strs.layout.empty_row.spec, buttons=[]), + dui2.ButtonRow( + title=strs.layout.layout_tests.spec, + debug=debug, + padding_left=5.0, + buttons=[ + dui2.Button( + label=strs.nav.test.spec, + size=(180, 200), + decorations=_layout_test_decos(debug), + ), + dui2.Button( + label=strs.nav.test_two.spec, + 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. + dui2.Button( + label=strs.nav.test.spec, + size=(180, 200), + scale=0.6, + padding_bottom=30, # Should nudge us up. + debug=debug, # Show bounds. + decorations=_layout_test_decos(debug), + ), + # Testing custom button images and opacity. + dui2.Button( + label=strs.nav.test_three.spec, + texture=builtinassets.textures.button_square_wide, + padding_left=10.0, + padding_right=10.0, + color=(1, 1, 1, 0.3), + size=(200, 100), + ), + # Testing image drawing vs bounds + dui2.Button( + label=strs.layout.bounds_test.spec, + texture=builtinassets.textures.white, + color=(1, 1, 1, 0.3), + size=(150, 100), + debug=debug, + ), + ], + ), + dui2.ButtonRow( + title=strs.layout.long_row_test.spec, + subtitle=strs.layout.look_a_subtitle.spec, + buttons=[ + dui2.Button( + size=(150, 100), + decorations=[ + dui2.Text( + text=strs.layout.max_width_test.spec, + position=(0, 25), + size=(150 * 0.8, 32.0), + flatness=1.0, + shadow=0.0, + debug=debug, + ), + # v1 bakes a newline into this one + # ('MaxHeightTest\nSecondLine'); we ask + # for two balanced lines instead. + dui2.Text( + text=strs.layout.max_height_test.spec, + position=(0, -20), + size=(150 * 0.8, 40), + flatness=1.0, + shadow=0.0, + debug=debug, + ), + ], + ), + dui2.Button( + size=(150, 100), + decorations=[ + dui2.Image( + texture=stdassets.textures.zoe_icon, + position=(0, 0), + size=(70, 70), + tint_texture=( + stdassets.textures.zoe_icon_color_mask + ), + tint_color=(1, 0, 0), + tint2_color=(0, 1, 0), + mask_texture=( + builtinassets.textures + ).character_icon_mask, + ), + ], + ), + dui2.Button( + size=(150, 100), + decorations=[ + dui2.Image( + texture=stdassets.textures.bridgit_preview, + position=(0, 10), + size=(120, 60), + mask_texture=( + stdassets.textures.map_preview_mask + ), + mesh_opaque=( + stdassets.meshes + ).level_select_button_opaque, + mesh_transparent=( + stdassets.meshes + ).level_select_button_transparent, + ), + ], + ), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button( + label=strs.common.foo.spec, + size=(150, 100), + scale=0.4, + padding_left=100, + padding_right=200, + ), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + dui2.Button(size=(150, 100)), + ], + ), + dui2.ButtonRow( + spacing_top=-15, + subtitle=strs.layout.subtitle_only.spec, + buttons=[ + dui2.Button(size=(200, 120)), + ], + ), + dui2.ButtonRow( + buttons=[ + dui2.Button( + label=strs.layout.row_with_no_title.spec, + size=(300, 80), + style=dui2.ButtonStyle.MEDIUM, + color=(0.8, 0.8, 0.8, 1), + icon=stdassets.textures.button_punch, + icon_color=(0.5, 0.3, 1.0, 1.0), + icon_scale=1.2, + ), + ], + ), + dui2.ButtonRow( + title=strs.layout.centered_faded_title.spec, + title_color=(0.6, 0.6, 1.0, 0.3), + title_flatness=1.0, + title_shadow=1.0, + subtitle=strs.layout.testing_centered.spec, + 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=[ + dui2.Button( + label=strs.common.hello_there.spec, + size=(200, 120), + color=(0.7, 0.7, 0.9, 1), ), ], ), @@ -248,102 +522,121 @@ def _test_page_long( ) ) - -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), - ), - ], + # Include some client effects if they ask (the 'Response + # ClientEffects' button). V2 effect forms; see the immediate-effects + # note above. + if request.args.get('test_effects', False): + response.client_effects = [ + clfx.ScreenMessageV2( + message=strs.effects.response_effects_hello.spec, + color=(0, 1, 0), ), - ], - ) + clfx.PlaySoundV2(sound=builtinassets.audio.cash_register), + clfx.Delay(1.0), + clfx.ScreenMessageV2( + message=strs.effects.effect_success.spec, color=(0, 1, 0) + ), + clfx.PlaySoundV2(sound=builtinassets.audio.cash_register), + ] + + # Include a local-action if they ask (the 'Response LocalAction' + # button). + if request.args.get('test_action', False): + response.local_action = 'testaction' + response.local_action_args = {'testparam': 234} + + return response -def _test_page_2( - request: bacommon.docui.v1.Request, -) -> bacommon.docui.v1.Response: - """More testing.""" - import bacommon.docui.v1 as dui1 +def _layout_test_decos(debug: bool) -> list[bacommon.docui.v2.Decoration]: + """Decorations for the layout-test buttons (used on two buttons).""" + import bacommon.docui.v2 as dui2 + + from bauiv1 import _docuiv2testassets + + strs = _docuiv2testassets.strings + + return [ + dui2.Image( + texture=stdassets.textures.powerup_punch, + position=(-70, 0), + size=(40, 40), + h_align=dui2.HAlign.LEFT, + ), + dui2.Image( + texture=stdassets.textures.powerup_speed, + position=(0, 75), + size=(35, 35), + v_align=dui2.VAlign.TOP, + ), + dui2.Text( + text=strs.layout.corner_tl.spec, + position=(-70, 75), + size=(50, 50), + h_align=dui2.HAlign.LEFT, + v_align=dui2.VAlign.TOP, + debug=debug, + ), + dui2.Text( + text=strs.layout.corner_tr.spec, + position=(70, 75), + size=(50, 50), + h_align=dui2.HAlign.RIGHT, + v_align=dui2.VAlign.TOP, + debug=debug, + ), + dui2.Text( + text=strs.layout.corner_bl.spec, + position=(-70, -75), + size=(50, 50), + h_align=dui2.HAlign.LEFT, + v_align=dui2.VAlign.BOTTOM, + debug=debug, + ), + dui2.Text( + text=strs.layout.corner_br.spec, + position=(70, -75), + size=(50, 50), + h_align=dui2.HAlign.RIGHT, + v_align=dui2.VAlign.BOTTOM, + debug=debug, + ), + ] + + +def _test_v2_page_2( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """More testing (v2 mirror of the v1 '/test2' page).""" + import bacommon.docui.v2 as dui2 + + from bauiv1 import _docuiv2testassets del request # Unused. - return dui1.Response( - page=dui1.Page( - title='Test 2', + strs = _docuiv2testassets.strings + + return dui2.Response( + page=dui2.Page( + title=strs.nav.test_two_title.spec, rows=[ - dui1.ButtonRow( - title='More Tests', + dui2.ButtonRow( + title=strs.nav.more_tests.spec, buttons=[ - dui1.Button( - 'Browse', + dui2.Button( + label=strs.nav.browse.spec, size=(120, 80), - action=dui1.Browse(dui1.Request('/')), + action=dui2.Browse(dui2.Request('/')), ), - dui1.Button( - 'Replace', + dui2.Button( + label=strs.nav.replace.spec, size=(120, 80), - action=dui1.Replace(dui1.Request('/')), + action=dui2.Replace(dui2.Request('/')), ), - dui1.Button( - 'Close', + dui2.Button( + label=strs.nav.close.spec, size=(120, 80), - action=dui1.Local(close_window=True), + action=dui2.Local(close_window=True), selected=True, # Testing this ), ], @@ -353,509 +646,118 @@ def _test_page_2( ) -def _test_page_root( - request: bacommon.docui.v1.Request, -) -> bacommon.docui.v1.Response: - """Return test page.""" +def _test_v2_page_long( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """Testing a page that takes a bit of time to load (v2 mirror).""" + import bacommon.docui.v2 as dui2 - import bacommon.clienteffect as clfx - import bacommon.docui.v1 as dui1 + from bauiv1 import _docuiv2testassets - # Show some specific debug bits if they ask us to. - debug = bool(request.args.get('debug', False)) + del request # Unused. - response = dui1.Response( - page=dui1.Page( - title='Test Root', + strs = _docuiv2testassets.strings + + # Simulate a slow connection or whatnot. + time.sleep(3.0) + + return dui2.Response( + page=dui2.Page( + title=strs.nav.test.spec, + center_vertically=True, 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( - _btex('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( - _stex('powerup_punch'), - position=(-70, 0), - size=(40, 40), - h_align=dui1.HAlign.LEFT, - ), - dui1.Image( - _stex('powerup_speed'), - 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( - _stex('powerup_punch'), - position=(-70, 0), - size=(40, 40), - h_align=dui1.HAlign.LEFT, - ), - dui1.Image( - _stex('powerup_speed'), - 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=_btex('button_square_wide'), - 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=_btex('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( - _stex('zoe_icon'), - position=(0, 0), - size=(70, 70), - tint_texture=_stex('zoe_icon_color_mask'), - tint_color=(1, 0, 0), - tint2_color=(0, 1, 0), - mask_texture=_btex('character_icon_mask'), - ), - ], - ), - dui1.Button( - size=(150, 100), - decorations=[ - dui1.Image( - _stex('bridgit_preview'), - position=(0, 10), - size=(120, 60), - mask_texture=_stex('map_preview_mask'), - 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=_stex('button_punch'), - 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, + dui2.ButtonRow( + title=strs.common.that_took_a_while.spec, center_title=True, + center_content=True, buttons=[ - dui1.Button( - 'Hello There!', - size=(200, 120), - color=(0.7, 0.7, 0.9, 1), + dui2.Button( + label=strs.common.sure_did.spec, + size=(120, 80), + action=dui2.Browse(dui2.Request('/')), + ), + ], + ), + ], + ) + ) + + +def _test_v2_page_timed_actions( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """Testing timed actions (v2 mirror of '/timedactions').""" + import bacommon.docui.v2 as dui2 + + from bauiv1 import _docuiv2testassets + + strs = _docuiv2testassets.strings + + val = request.args.get('val') + if not isinstance(val, int): + val = 5 + + return dui2.Response( + page=dui2.Page( + title=strs.nav.test.spec, + center_vertically=True, + rows=[ + dui2.ButtonRow( + title=strs.common.hello_there_num(num=str(val)).spec, + subtitle=strs.common.each_change.spec, + center_title=True, + center_content=True, + buttons=[ + dui2.Button( + label=strs.nav.done.spec, + size=(120, 80), + action=dui2.Local(close_window=True), + default=True, ), ], ), ], ), + # Refresh this page with a countdown until we hit zero and then + # close the window. + timed_action=( + dui2.Replace(dui2.Request('/timedactions', args={'val': val - 1})) + if (val - 1) > 0 + else dui2.Local(close_window=True) + ), + timed_action_delay=1.0, ) - # 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} +def _test_v2_page_empty( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """An empty page (v2 mirror of '/emptypage').""" + import bacommon.docui.v2 as dui2 - return response - - -def _test_page_empty( - request: bacommon.docui.v1.Request, -) -> bacommon.docui.v1.Response: - import bacommon.docui.v1 as dui1 + from bauiv1 import _docuiv2testassets del request # Unused. - return dui1.Response(page=dui1.Page(title='EmptyPage', rows=[])) + return dui2.Response( + page=dui2.Page( + title=_docuiv2testassets.strings.layout.empty_page_title.spec, + rows=[], + ) + ) -def _test_page_display_items( - request: bacommon.docui.v1.Request, -) -> bacommon.docui.v1.Response: - """Testing display-items.""" +def _test_v2_page_display_items( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """Testing display-items (v2 mirror of '/displayitems').""" from bacommon.classic import ClassicChestAppearance, ClassicChestDisplayItem - import bacommon.docui.v1 as dui1 import bacommon.displayitem as ditm + import bacommon.docui.v2 as dui2 + + from bauiv1 import _docuiv2testassets + + strs = _docuiv2testassets.strings # Show some specific debug bits if they ask us to. debug = bool(request.args.get('debug', False)) @@ -863,55 +765,55 @@ def _test_page_display_items( def _make_test_button( scale: float, wrapper: ditm.Wrapper, - ) -> dui1.Button: + ) -> bacommon.docui.v2.Button: # See how this looks when unrecognized (relying on wrapper info # only). uwrapper = copy.deepcopy(wrapper) uwrapper.item = ditm.Unknown() - return dui1.Button( + return dui2.Button( size=(300, 400), scale=scale, decorations=[ - dui1.DisplayItem( + dui2.DisplayItem( wrapper=wrapper, - style=dui1.DisplayItemStyle.FULL, + style=dui2.DisplayItemStyle.FULL, position=(-62, 100), size=(120, 120), debug=debug, ), - dui1.DisplayItem( + dui2.DisplayItem( wrapper=uwrapper, - style=dui1.DisplayItemStyle.FULL, + style=dui2.DisplayItemStyle.FULL, position=(62, 100), size=(120, 120), debug=debug, ), - dui1.DisplayItem( + dui2.DisplayItem( wrapper=wrapper, - style=dui1.DisplayItemStyle.COMPACT, + style=dui2.DisplayItemStyle.COMPACT, position=(-55, -20), size=(80, 80), debug=debug, ), - dui1.DisplayItem( + dui2.DisplayItem( wrapper=uwrapper, - style=dui1.DisplayItemStyle.COMPACT, + style=dui2.DisplayItemStyle.COMPACT, position=(55, -20), size=(80, 80), debug=debug, ), - dui1.DisplayItem( + dui2.DisplayItem( wrapper=wrapper, - style=dui1.DisplayItemStyle.ICON, + style=dui2.DisplayItemStyle.ICON, position=(-55, -120), size=(100, 80), debug=debug, ), - dui1.DisplayItem( + dui2.DisplayItem( wrapper=uwrapper, - style=dui1.DisplayItemStyle.ICON, + style=dui2.DisplayItemStyle.ICON, position=(55, -120), size=(100, 80), debug=debug, @@ -919,20 +821,17 @@ def _test_page_display_items( ], ) - return dui1.Response( - page=dui1.Page( + return dui2.Response( + page=dui2.Page( padding_left=20, padding_right=20, - title='DisplayItems', + title=strs.items.display_items.spec, rows=[ - dui1.ButtonRow( + dui2.ButtonRow( debug=debug, padding_left=-10, - title='Display Item Tests', - subtitle=( - 'top=FULL, center=COMPACT, bottom=ICON;' - ' left=regular, right=unknown' - ), + title=strs.items.display_item_tests.spec, + subtitle=strs.items.display_items_sub.spec, buttons=[ _make_test_button( 1.0, @@ -964,15 +863,19 @@ def _test_page_display_items( ), ], ), - dui1.ButtonRow( + dui2.ButtonRow( buttons=[ - dui1.Button( - 'Hide Debug' if debug else 'Show Debug', - style=dui1.ButtonStyle.MEDIUM, + dui2.Button( + label=( + strs.common.hide_debug.spec + if debug + else strs.common.show_debug.spec + ), + style=dui2.ButtonStyle.MEDIUM, size=(240, 60), color=(0.6, 0.4, 0.8, 1.0), - action=dui1.Replace( - dui1.Request( + action=dui2.Replace( + dui2.Request( request.path, args={'debug': not debug} ) ), @@ -984,237 +887,102 @@ def _test_page_display_items( ) -def _test_bounds( - request: bacommon.docui.v1.Request, -) -> bacommon.docui.v1.Response: - import bacommon.docui.v1 as dui1 +def _test_v2_bounds( + request: bacommon.docui.v2.Request, +) -> bacommon.docui.v2.Response: + """Button-style bounds tests (v2 mirror of '/boundstests').""" + import bacommon.docui.v2 as dui2 + + from bauiv1 import _docuiv2testassets del request # Unused. - def _nm(style: dui1.ButtonStyle) -> str: - return f'{type(style).__name__}.{style.name}' + strs = _docuiv2testassets.strings - return dui1.Response( - page=dui1.Page( - title='BoundsTests', + def _nm(style: bacommon.docui.v2.ButtonStyle) -> LangStrSpec: + # Code-literal pass-through: renders the raw enum name + # verbatim in every locale. + return strs.common.code_literal( + text=f'{type(style).__name__}.{style.name}' + ).spec + + def _hello_row( + title: LangStrSpec, + sizes: list[tuple[float, float]], + style: bacommon.docui.v2.ButtonStyle, + texture: bool = False, + ) -> bacommon.docui.v2.ButtonRow: + return dui2.ButtonRow( + title=title, + buttons=[ + dui2.Button( + label=strs.layout.hello.spec, + size=size, + style=style, + texture=(builtinassets.textures.white if texture else None), + color=(1, 0, 0, 0.3) if texture else None, + debug=True, + ) + for size in sizes + ], + ) + + styles = dui2.ButtonStyle + + return dui2.Response( + page=dui2.Page( + title=strs.layout.bounds_tests_title.spec, 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), - ], + _hello_row( + _nm(styles.SQUARE), + [(300, 300), (200, 200), (100, 100)], + styles.SQUARE, ), - 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, - ), - ], + _hello_row( + _nm(styles.SQUARE_WIDE), + [(400, 200), (200, 250), (60, 100)], + styles.SQUARE_WIDE, ), - dui1.ButtonRow( - title='(background texture)', - buttons=[ - dui1.Button( - 'Hello', - size=(300, 300), - texture=_btex('white'), - color=(1, 0, 0, 0.3), - debug=True, - ), - dui1.Button( - 'Hello', - size=(200, 200), - texture=_btex('white'), - color=(1, 0, 0, 0.3), - debug=True, - ), - dui1.Button( - 'Hello', - size=(100, 100), - texture=_btex('white'), - color=(1, 0, 0, 0.3), - debug=True, - ), - ], + _hello_row( + strs.layout.background_texture.spec, + [(300, 300), (200, 200), (100, 100)], + styles.SQUARE, + texture=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, - ), - ], + _hello_row( + _nm(styles.TAB), + [(400, 100), (200, 50), (100, 60)], + styles.TAB, ), - 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, - ), - ], + _hello_row( + _nm(styles.LARGER), + [(500, 100), (200, 50), (100, 60)], + styles.LARGER, ), - 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, - ), - ], + _hello_row( + _nm(styles.LARGE), + [(400, 100), (200, 50), (100, 60)], + styles.LARGE, ), - 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, - ), - ], + _hello_row( + _nm(styles.MEDIUM), + [(300, 100), (200, 50), (100, 60)], + styles.MEDIUM, ), - 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, - ), - ], + _hello_row( + _nm(styles.SMALL), + [(200, 100), (200, 50), (100, 60)], + styles.SMALL, ), - 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, - ), - ], + _hello_row( + _nm(styles.BACK), + [(200, 100), (200, 50), (100, 60)], + styles.BACK, ), - 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, - ), - ], + _hello_row( + _nm(styles.BACK_SMALL), + [(200, 100), (200, 50), (100, 60)], + styles.BACK_SMALL, ), ], ) diff --git a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py index 068dd2a..9b51b27 100644 --- a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py +++ b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py @@ -111,7 +111,9 @@ class NetScanner: 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'], + # Show the host's party name when they're advertising + # one (v2 scan responses); otherwise their player name. + text=host['party_name'] or host['display_string'], h_align='left', v_align='center', corner_scale=t_scale, diff --git a/dist/ba_data/python/bauiv1lib/gather/publictab.py b/dist/ba_data/python/bauiv1lib/gather/publictab.py index 1466ab0..f9f81c1 100644 --- a/dist/ba_data/python/bauiv1lib/gather/publictab.py +++ b/dist/ba_data/python/bauiv1lib/gather/publictab.py @@ -384,6 +384,7 @@ class PublicGatherTab(GatherTab): self._update_timer: bui.AppTimer | None = None self._host_scrollwidget: bui.Widget | None = None self._host_name_text: bui.Widget | None = None + self._host_password_text: bui.Widget | None = None self._host_toggle_button: bui.Widget | None = None self._last_server_list_query_time: float | None = None self._join_list_column: bui.Widget | None = None @@ -738,9 +739,9 @@ class PublicGatherTab(GatherTab): c_width = region_width c_height = region_height - 20 v = c_height - 35 - v -= 25 + v -= 20 is_public_enabled = bs.get_public_party_enabled() - v -= 30 + v -= 25 bui.textwidget( parent=self._container, @@ -754,7 +755,7 @@ class PublicGatherTab(GatherTab): position=(region_width * 0.5, v + 10), text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'), ) - v -= 30 + v -= 20 # Nudge party name and size values to be mostly centered. xoffs = region_width * 0.5 - 500 @@ -791,6 +792,37 @@ class PublicGatherTab(GatherTab): corner_scale=1.0, ) + v -= 60 + party_password_text = stdassets.strings.gather.password_optional + bui.textwidget( + parent=self._container, + size=(0, 0), + h_align='right', + v_align='center', + maxwidth=200, + scale=0.8, + color=bui.app.ui_v1.infotextcolor, + position=(210 + xoffs, v - 9), + text=party_password_text, + ) + self._host_password_text = bui.textwidget( + parent=self._container, + id=f'{self._idprefix}|hostingpassword', + editable=True, + size=(535, 40), + position=(230 + xoffs, v - 30), + text=bui.app.config.get('Public Party Password', ''), + maxwidth=494, + max_chars=100, + password=True, + shadow=0.3, + flatness=1.0, + description=party_password_text.evaluate(), + autoselect=True, + v_align='center', + corner_scale=1.0, + ) + v -= 60 bui.textwidget( parent=self._container, @@ -838,8 +870,8 @@ class PublicGatherTab(GatherTab): label='+', autoselect=True, ) - v -= 50 - v -= 70 + v -= 45 + v -= 90 if is_public_enabled: label = bui.Lstr( resource='gatherWindow.makePartyPrivateText', @@ -864,9 +896,12 @@ class PublicGatherTab(GatherTab): autoselect=True, up_widget=btn2, ) - bui.widget(edit=self._host_name_text, down_widget=btn2) - bui.widget(edit=btn2, up_widget=self._host_name_text) - bui.widget(edit=btn1, up_widget=self._host_name_text) + bui.widget( + edit=self._host_name_text, down_widget=self._host_password_text + ) + bui.widget(edit=self._host_password_text, down_widget=btn2) + bui.widget(edit=btn2, up_widget=self._host_password_text) + bui.widget(edit=btn1, up_widget=self._host_password_text) assert self._join_text is not None bui.widget(edit=self._join_text, down_widget=self._host_name_text) v -= 10 @@ -985,11 +1020,19 @@ class PublicGatherTab(GatherTab): self._update_party_lists() # If we've got a party-name text widget, keep its value plugged - # into our public host name. + # into our public host name — but only while we're actually + # advertising; otherwise the name would linger in things like + # LAN-scan responses after we stop. (The make-party-public press + # reads the widget directly, so nothing is lost by not syncing + # beforehand.) text = self._host_name_text - if text: + if text and bs.get_public_party_enabled(): name = cast(str, bui.textwidget(query=self._host_name_text)) bs.set_public_party_name(name) + # Same story for the password field. + pwtext = self._host_password_text + if pwtext: + bs.set_host_password(cast(str, bui.textwidget(query=pwtext))) # Update status text and loading spinner. if self._join_status_text: @@ -1447,8 +1490,11 @@ class PublicGatherTab(GatherTab): builtinassets.audio.error.get().play() return bs.set_public_party_name(name) + password = cast(str, bui.textwidget(query=self._host_password_text)) + bs.set_host_password(password) cfg = bui.app.config cfg['Public Party Name'] = name + cfg['Public Party Password'] = password cfg.commit() stdassets.audio.shield_up.get().play() bs.set_public_party_enabled(True) @@ -1470,6 +1516,14 @@ class PublicGatherTab(GatherTab): def _on_stop_advertising_press(self) -> None: bs.set_public_party_enabled(False) + # Clear the party name so things like LAN-scan responses don't + # keep advertising it once we're no longer public. (Our stored + # config value survives for pre-filling the UI next time.) + bs.set_public_party_name('') + + # Ditto for the password requirement. + bs.set_host_password('') + # In GUI builds we want to authenticate clients only when # hosting public parties. bs.set_authenticate_clients(False) diff --git a/dist/ba_data/python/bauiv1lib/inventory.py b/dist/ba_data/python/bauiv1lib/inventory.py index cc05ebd..460cc45 100644 --- a/dist/ba_data/python/bauiv1lib/inventory.py +++ b/dist/ba_data/python/bauiv1lib/inventory.py @@ -7,7 +7,9 @@ import random from typing import override, TYPE_CHECKING from efro.util import asserttype -import bacommon.docui.v1 as dui1 +import bacommon.docui.v2 as dui2 +from bacommon.assetref import TextureSpec +from bacommon.langstr import LangStrSpecValue import bauiv1 as bui from bauiv1 import builtinassets from bauiv1 import stdassets @@ -22,14 +24,14 @@ if TYPE_CHECKING: from bauiv1lib.docui import DocUILocalAction, DocUIWindow -def _stex(name: str) -> str: - """Qualified stdassets texture ref.""" - return f'{stdassets.__asset_package__}:textures/{name}' +def _tex_from_qualified(qualified: str) -> TextureSpec: + """Typed ref for a qualified ``:`` texture string. - -def _btex(name: str) -> str: - """Qualified ref for a texture in the builtin asset-package.""" - return f'{builtinassets.__asset_package__}:textures/{name}' + (Appearance texture fields carry qualified strings; docui v2 wants + typed refs.) + """ + apverid, _, name = qualified.partition(':') + return TextureSpec(apverid, name) class InventoryUIController(DocUIController): @@ -41,26 +43,35 @@ class InventoryUIController(DocUIController): @override def fulfill_request(self, request: DocUIRequest) -> DocUIResponse: + # All local authoring here uses strings from BUNDLED packages + # (bastdassets/builtin) so these pages keep working offline. + invstrs = stdassets.strings.inventory + profstrs = stdassets.strings.profiles response: DocUIResponse # 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, + response = dui2.Response( + page=dui2.Page( + title=invstrs.title.spec, rows=[], ) ) else: # *Most* of our inventory comes from the cloud - we just supply # profiles ourself so it works offline. - response = self.fulfill_request_cloud(request, 'classicinventory') + cloudresponse = self.fulfill_request_cloud( + request, 'classicinventory' + ) - assert isinstance(request, dui1.Request) - assert isinstance(response, dui1.Response) + assert isinstance(request, dui2.Request) + if not isinstance(cloudresponse, dui2.Response): + # A server that doesn't speak v2 for us; show its + # response as-is (no local additions). + return cloudresponse + response = cloudresponse if request.path != '/': return response @@ -72,25 +83,21 @@ class InventoryUIController(DocUIController): # 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, + if response.status is not dui2.ResponseStatus.SUCCESS: + response = dui2.Response( + page=dui2.Page( + title=invstrs.title.spec, rows=[ - dui1.ButtonRow( + dui2.ButtonRow( center_content=True, buttons=[ - dui1.Button( + dui2.Button( ( - inv_only_signin_t + invstrs.only_available_signed_in if not signed_in - else inv_only_online_t - ), - label_is_lstr=True, - texture=_btex('white'), + else invstrs.only_available_online + ).spec, + texture=builtinassets.textures.white, size=(600, 100), color=(1, 1, 1, 0.0), label_scale=0.7, @@ -102,52 +109,44 @@ class InventoryUIController(DocUIController): ), ) + # Wire spawn-bot actions onto any buttons the server marked + # (structured ids; no display-text sniffing). for row in response.page.rows: - if ( - isinstance(row, dui1.ButtonRow) - and row.title - and '"r":"store.yourCharactersText"' in row.title - ): + if isinstance(row, dui2.ButtonRow): 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 + wid = button.widget_id + if wid is not None and wid.startswith('spawn_char:'): + button.action = dui2.Local( + immediate_local_action='spawn_bot', + immediate_local_action_args={ + 'name': wid.removeprefix('spawn_char:') + }, + ) # 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, + dui2.ButtonRow( + title=profstrs.title.spec, + subtitle=profstrs.explanation.spec, button_spacing=15, buttons=self._get_profile_buttons(), ), - dui1.ButtonRow( + dui2.ButtonRow( spacing_top=-15, spacing_bottom=15, padding_left=13, buttons=[ - dui1.Button( - '{"r":"editProfileWindow.titleNewText"}', - dui1.Local( + dui2.Button( + profstrs.new_profile.spec, + action=dui2.Local( default_sound=False, immediate_local_action='new_profile', ), - icon=_stex('plus_button'), + icon=stdassets.textures.plus_button, icon_scale=1.3, icon_color=(0.7, 0.6, 0.9, 1), - label_is_lstr=True, - style=dui1.ButtonStyle.MEDIUM, + style=dui2.ButtonStyle.MEDIUM, size=(210, 60), scale=0.8, color=(0.6, 0.5, 0.8, 1.0), @@ -179,7 +178,7 @@ class InventoryUIController(DocUIController): ) -> None: """Called when a window shared state is being restored.""" - if not isinstance(window.request, dui1.Request): + if not isinstance(window.request, dui2.Request): return # If desired, set the profile button that will be selected in @@ -234,14 +233,14 @@ class InventoryUIController(DocUIController): if session is not None: session.handlemessage(bs.PlayerProfilesChangedMessage()) - def _get_profile_buttons(self) -> list[dui1.Button]: + def _get_profile_buttons(self) -> list[dui2.Button]: plus = bui.app.plus assert plus is not None classic = bui.app.classic assert classic is not None - buttons: list[dui1.Button] = [] + buttons: list[dui2.Button] = [] profiles = bui.app.config.get('Player Profiles', {}) items = list(profiles.items()) @@ -275,10 +274,10 @@ class InventoryUIController(DocUIController): appearance = spaz_appearance_default buttons.append( - dui1.Button( - texture=_btex('white'), + dui2.Button( + texture=builtinassets.textures.white, size=(145, 175), - action=dui1.Local( + action=dui2.Local( default_sound=False, immediate_local_action='edit_profile', immediate_local_action_args={'profile': p_name}, @@ -287,17 +286,22 @@ class InventoryUIController(DocUIController): color=(1, 1, 1, 0.0), widget_id=f'profile.{p_name}', decorations=[ - dui1.Image( - appearance.icon_texture, + dui2.Image( + _tex_from_qualified(appearance.icon_texture), position=(0, 15), size=(140, 140), - mask_texture=_btex('character_icon_mask'), - tint_texture=appearance.icon_mask_texture, + mask_texture=( + builtinassets.textures.character_icon_mask + ), + tint_texture=_tex_from_qualified( + appearance.icon_mask_texture + ), tint_color=color, tint2_color=highlight, ), - dui1.Text( - tval, + dui2.Text( + # Raw profile name (+icon glyph); verbatim. + LangStrSpecValue(tval), position=(0, -75), size=(130, 40), flatness=1.0, @@ -386,6 +390,9 @@ class InventoryUIController(DocUIController): name = action.args.get('name') assert isinstance(name, str) + # Modern flow passes the exact internal appearance name (from + # the server's spawn_char widget-id markers); the legacy scan + # below also tolerates old Lstr-JSON display strings. activity = bs.get_foreground_host_activity() if not isinstance(activity, MainMenuActivity) or activity.map is None: @@ -400,7 +407,7 @@ class InventoryUIController(DocUIController): else: activity.bot_sets.pop(i) for appearance in get_appearances(True): - if f'"{appearance}"' in name: + if appearance == name or f'"{appearance}"' in name: with activity.context: bot_set = DemoSpazBotSet() DemoBot.randomize_traits(appearance) diff --git a/dist/ba_data/python/bauiv1lib/league/rankwindow.py b/dist/ba_data/python/bauiv1lib/league/rankwindow.py index 9ab8110..0257dfd 100644 --- a/dist/ba_data/python/bauiv1lib/league/rankwindow.py +++ b/dist/ba_data/python/bauiv1lib/league/rankwindow.py @@ -856,7 +856,7 @@ class LeagueRankWindow(bui.MainWindow): ) def _on_president_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.league.presidency import LeaguePresidencyUIController from bauiv1lib.connectivity import wait_for_connectivity @@ -882,7 +882,7 @@ class LeagueRankWindow(bui.MainWindow): on_connected=lambda: self.main_window_replace( bui.CallStrict( LeaguePresidencyUIController().create_window, - dui1.Request('/', args={'season': self._season}), + dui2.Request('/', args={'season': self._season}), origin_widget=self._president_button, auxiliary_style=False, ), diff --git a/dist/ba_data/python/bauiv1lib/passwordprompt.py b/dist/ba_data/python/bauiv1lib/passwordprompt.py new file mode 100644 index 0000000..c59604f --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/passwordprompt.py @@ -0,0 +1,128 @@ +# Released under the MIT License. See LICENSE for details. +# +"""A minimal password-entry prompt dialog.""" + +from typing import TYPE_CHECKING, cast + +import bauiv1 as bui +from bauiv1 import stdassets + +if TYPE_CHECKING: + from typing import Callable + + +class PasswordPromptWindow: + """Small modal overlay window prompting for a password. + + Calls ``on_result`` exactly once: the entered password on submit or + None on cancel (via the cancel button, back press, or an external + :meth:`dismiss`). + """ + + def __init__( + self, + *, + description: str | bui.Lstr | bui.LangStr | None = None, + on_result: Callable[[str | None], None] | None = None, + ): + ui = bui.app.ui_v1 + + # Make sure our widgets have globally unique ids. + self._id_prefix = ui.new_id_prefix('passwordprompt') + + self._on_result = on_result + self._result_sent = False + + if description is None: + description = stdassets.strings.gather.party_requires_password + + width = 420.0 + height = 200.0 + uiscale = ui.uiscale + self._root_widget = bui.containerwidget( + size=(width, height), + transition='in_scale', + toolbar_visibility='menu_minimal_no_back', + parent=bui.get_special_widget('overlay_stack'), + scale=( + 1.9 + if uiscale is bui.UIScale.SMALL + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + ), + darken_behind=True, + ) + bui.textwidget( + parent=self._root_widget, + position=(width * 0.5, height - 40), + size=(0, 0), + h_align='center', + v_align='center', + text=description, + maxwidth=width * 0.9, + ) + self._text_field = bui.textwidget( + parent=self._root_widget, + id=f'{self._id_prefix}|password', + editable=True, + size=(width - 80, 40), + position=(40, height - 110), + text='', + maxwidth=width - 100, + max_chars=100, + autoselect=True, + v_align='center', + password=True, + description=( + description + if isinstance(description, (str, bui.Lstr)) + else description.evaluate() + ), + on_return_press_call=self._submit, + ) + cbtn = bui.buttonwidget( + parent=self._root_widget, + id=f'{self._id_prefix}|cancel', + autoselect=True, + position=(20, 20), + size=(150, 50), + label=stdassets.strings.ui.cancel, + on_activate_call=self._cancel, + ) + okbtn = bui.buttonwidget( + parent=self._root_widget, + id=f'{self._id_prefix}|ok', + autoselect=True, + position=(width - 175, 20), + size=(150, 50), + label=stdassets.strings.ui.ok, + on_activate_call=self._submit, + ) + bui.containerwidget( + edit=self._root_widget, + cancel_button=cbtn, + start_button=okbtn, + selected_child=self._text_field, + ) + + def dismiss(self) -> None: + """Externally dismiss the prompt (counts as a cancel). Idempotent.""" + self._cancel() + + def _send_result(self, result: str | None) -> None: + if self._result_sent: + return + self._result_sent = True + if self._on_result is not None: + self._on_result(result) + + def _submit(self) -> None: + if not self._root_widget: + return + password = cast(str, bui.textwidget(query=self._text_field)) + bui.containerwidget(edit=self._root_widget, transition='out_scale') + self._send_result(password) + + def _cancel(self) -> None: + if self._root_widget: + bui.containerwidget(edit=self._root_widget, transition='out_scale') + self._send_result(None) diff --git a/dist/ba_data/python/bauiv1lib/playlist/addgame.py b/dist/ba_data/python/bauiv1lib/playlist/addgame.py index 73559ec..fa3cfa4 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/addgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/addgame.py @@ -281,7 +281,7 @@ class PlaylistAddGameWindow(bui.MainWindow): ) def _on_get_more_games_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.docui import DocUIWindow from bauiv1lib.account.signin import show_sign_in_prompt @@ -303,7 +303,7 @@ class PlaylistAddGameWindow(bui.MainWindow): win_type=DocUIWindow, win_create_call=bui.CallStrict( StoreUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=self._get_more_games_button, uiopenstateid='classicstore', ), diff --git a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py index e4f1320..2282587 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py +++ b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py @@ -288,7 +288,7 @@ class PlaylistMapSelectWindow(bui.MainWindow): ) def _on_store_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.docui import DocUIWindow from bauiv1lib.connectivity import wait_for_connectivity @@ -317,7 +317,7 @@ class PlaylistMapSelectWindow(bui.MainWindow): win_type=DocUIWindow, win_create_call=bui.CallStrict( StoreUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=self._get_more_maps_button, uiopenstateid='classicstore', ), diff --git a/dist/ba_data/python/bauiv1lib/profile/edit.py b/dist/ba_data/python/bauiv1lib/profile/edit.py index 52e9732..6975e38 100644 --- a/dist/ba_data/python/bauiv1lib/profile/edit.py +++ b/dist/ba_data/python/bauiv1lib/profile/edit.py @@ -747,7 +747,7 @@ class EditProfileWindow( @override def on_icon_picker_get_more_press(self) -> None: """User wants to get more icons.""" - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.store import StoreUIController @@ -766,7 +766,7 @@ class EditProfileWindow( on_connected=lambda: self.main_window_replace( bui.CallStrict( StoreUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=bui.get_special_widget('store_button'), auxiliary_style=False, ), @@ -806,7 +806,7 @@ class EditProfileWindow( @override def on_character_picker_get_more_press(self) -> None: - import bacommon.docui.v1 as dui1 + import bacommon.docui.v2 as dui2 from bauiv1lib.store import StoreUIController @@ -823,7 +823,7 @@ class EditProfileWindow( on_connected=lambda: self.main_window_replace( bui.CallStrict( StoreUIController().create_window, - dui1.Request('/'), + dui2.Request('/'), origin_widget=bui.get_special_widget('store_button'), auxiliary_style=False, ), diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepad.py b/dist/ba_data/python/bauiv1lib/settings/gamepad.py index b66708b..026c77f 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepad.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepad.py @@ -982,7 +982,6 @@ class AwaitGamepadInputWindow(bui.Window): message: bui.Lstr | None = None, message2: bui.Lstr | None = None, ): - # pylint: disable=too-many-positional-arguments if message is None: print('AwaitGamepadInputWindow message is None!') # Shouldn't get here. diff --git a/dist/ba_data/python/bauiv1lib/settings/keyboard.py b/dist/ba_data/python/bauiv1lib/settings/keyboard.py index d538af1..40e73b5 100644 --- a/dist/ba_data/python/bauiv1lib/settings/keyboard.py +++ b/dist/ba_data/python/bauiv1lib/settings/keyboard.py @@ -275,7 +275,6 @@ class ConfigKeyboardWindow(bui.MainWindow): button: str, scale: float = 1.0, ) -> None: - # pylint: disable=too-many-positional-arguments base_size = 79 btn = bui.buttonwidget( parent=self._root_widget, diff --git a/dist/ba_data/python/efro/cloudshell.py b/dist/ba_data/python/efro/cloudshell.py index 8e48edf..3b67862 100644 --- a/dist/ba_data/python/efro/cloudshell.py +++ b/dist/ba_data/python/efro/cloudshell.py @@ -51,22 +51,44 @@ class HostConfig: def socks_proxy_ssh_args() -> list[str]: """Return ssh ``-oProxyCommand`` args for a SOCKS5 proxy, if one is set. - When ``ALL_PROXY`` is a ``socks5://`` url -- e.g. under a network - sandbox that only permits outbound traffic through its proxy -- this - returns ``['-oProxyCommand=...']`` so ssh can reach allowed hosts via - it. To use these with rsync, fold them into ``--rsh`` with - :func:`shlex.join` (``'--rsh=' + shlex.join(['ssh', *args])``) so the - multi-word proxy command survives rsync's shell re-parse. Returns an - empty list when no socks5 proxy is set, so it is safe to splice into a - command unconditionally. + Under a network sandbox that only permits outbound traffic through its + proxy -- e.g. Claude Code's -- this returns ``['-oProxyCommand=...']`` so + ssh can reach allowed hosts via it. To use these with rsync, fold them + into ``--rsh`` with :func:`shlex.join` (``'--rsh=' + shlex.join(['ssh', + *args])``) so the multi-word proxy command survives rsync's shell + re-parse. Returns an empty list when no socks5 proxy is set, so it is + safe to splice into a command unconditionally. + + We need a SOCKS5 endpoint for ssh's ``ProxyCommand``. Historically + ``ALL_PROXY`` carried the ``socks5://`` url, but some sandboxes now set + ``ALL_PROXY`` to an ``http://`` url for the *same* endpoint and advertise + the socks5 form only via other vars (``ftp_proxy`` / ``grpc_proxy``). So + rather than trust ``ALL_PROXY``'s scheme, scan the common proxy vars and + use the first genuine ``socks5[h]://`` url we find. """ import os import shutil from efro.error import CleanError - proxy = os.environ.get('ALL_PROXY', '') - if not proxy.startswith(('socks5://', 'socks5h://')): + proxy = '' + for var in ( + 'SOCKS5_PROXY', + 'socks5_proxy', + 'SOCKS_PROXY', + 'socks_proxy', + 'ALL_PROXY', + 'all_proxy', + 'FTP_PROXY', + 'ftp_proxy', + 'GRPC_PROXY', + 'grpc_proxy', + ): + val = os.environ.get(var, '') + if val.startswith(('socks5://', 'socks5h://')): + proxy = val + break + if not proxy: return [] netloc = proxy.split('://', 1)[1].rstrip('/') diff --git a/dist/ba_data/python/efro/dataclassio/_base.py b/dist/ba_data/python/efro/dataclassio/_base.py index 5a726de..7b54b19 100644 --- a/dist/ba_data/python/efro/dataclassio/_base.py +++ b/dist/ba_data/python/efro/dataclassio/_base.py @@ -177,6 +177,34 @@ class IOMultiType[EnumT: Enum]: """ return '_t' + @classmethod + def get_default_type_id(cls) -> EnumT | None: + """Return a type-id to be assumed when none is present. + + By default, dataclassio errors when deserializing multitype + data that contains no type-id value. Overriding this to return + a type-id changes that behavior: data with no type-id present + will be deserialized as the returned type, and instances of + that type will be serialized *without* a type-id value. This + both saves a bit of space and allows 'upgrading' an existing + regular dataclass to a multitype - simply designate the + original dataclass type as the default and old serialized data + will remain loadable (and data for the default type will remain + loadable by old code). + + Be aware of the following, however: + + - Once serialized data exists anywhere without type-id values, + the default type-id must never be changed or removed; doing + so would cause that existing data to be silently + reinterpreted as some other type (or to error). + - A missing type-id normally acts as a sanity check when + deserializing; defining a default effectively disables that + check, meaning malformed data may deserialize successfully + as the default type instead of erroring. + """ + return None + # NOTE: Currently (Jan 2025) mypy complains if overrides annotate # return type of 'Self | None'. Substituting their own explicit type # works though (see test_dataclassio). @@ -501,6 +529,50 @@ def _raise_type_error( ) +def _select_union_member_type( + childanntypes: list[Any], value: Any +) -> Any | None: + """Select the member of a type-disjoint union matching a value. + + Multi-member unions (beyond the simple Optional form) are required + at prep time to be 'type-disjoint': each member must map to a + distinct wire type, so a value can be matched to its member with no + tagging. This does that matching. It works both for wire data + (where object-shaped members appear as dicts) and for in-memory + values (where they appear as dataclass instances). None members + are expected to be filtered out by the caller (along with None + values). Returns the matching member annotation type, or None if + nothing matches. + """ + valtype = type(value) + float_member: Any = None + object_member: Any = None + for childtype in childanntypes: + childorigin = _get_origin(childtype) + if childorigin is valtype: + return childtype + if childorigin is float: + float_member = childtype + elif isinstance(childorigin, type) and ( + dataclasses.is_dataclass(childorigin) + or issubclass(childorigin, IOMultiType) + ): + object_member = childtype + + # No exact match. Int values can land on a float member (the float + # handling there applies the usual coercion rules), and dict values + # (wire form) or dataclass instances (in-memory form, including + # subclasses such as IOMultiType members) land on the object-shaped + # member. + if valtype is int and float_member is not None: + return float_member + if object_member is not None and ( + isinstance(value, dict) or dataclasses.is_dataclass(valtype) + ): + return object_member + return None + + def _is_valid_for_codec(obj: Any, codec: Codec) -> bool: """Return whether a value consists solely of json-supported types. @@ -583,6 +655,11 @@ def _get_multitype_type( storename = cls.get_type_id_storage_name() id_val = val.get(storename) if id_val is None: + # A missing type-id is allowed if the multitype designates a + # default type; otherwise it's an error. + default_type_id = cls.get_default_type_id() + if default_type_id is not None: + return cls.get_type_cached(default_type_id) raise ValueError( f"Expected a '{storename}'" f" value for object at '{fieldpath}'." ) diff --git a/dist/ba_data/python/efro/dataclassio/_inputter.py b/dist/ba_data/python/efro/dataclassio/_inputter.py index f35c8d8..342aab1 100644 --- a/dist/ba_data/python/efro/dataclassio/_inputter.py +++ b/dist/ba_data/python/efro/dataclassio/_inputter.py @@ -23,6 +23,7 @@ from efro.dataclassio._base import ( _get_origin, SIMPLE_TYPES, _raise_type_error, + _select_union_member_type, IOExtendedData, _get_multitype_type, IOMultiType, @@ -86,21 +87,29 @@ class _Inputter: storename = self._cls.get_type_id_storage_name() type_id_val = values.get(storename) if type_id_val is None: - raise ValueError( - f'\'{storename}\' type id value' - f' not found in \'{self._cls.__name__}\' input data.' - ) - type_id_enum = self._cls.get_type_id_type() - try: - enum_val = type_id_enum(type_id_val) - except ValueError as exc: + # A missing type-id is allowed if the multitype + # designates a default type; otherwise it's an error. + default_type_id = self._cls.get_default_type_id() + if default_type_id is None: + raise ValueError( + f'\'{storename}\' type id value' + f' not found in \'{self._cls.__name__}\' input data.' + ) + enum_val = default_type_id + else: + type_id_enum = self._cls.get_type_id_type() + try: + enum_val = type_id_enum(type_id_val) + except ValueError as exc: - fallback_obj = self._get_fallback_object(exc, 'unrecognized') - if fallback_obj is not None: - return fallback_obj + fallback_obj = self._get_fallback_object( + exc, 'unrecognized' + ) + if fallback_obj is not None: + return fallback_obj - # Otherwise the error stands as-is. - raise + # Otherwise the error stands as-is. + raise try: outcls = self._cls.get_type_cached(enum_val) @@ -170,7 +179,6 @@ class _Inputter: ioattrs: IOAttrs | None, ) -> Any: """Convert an assigned value to what a dataclass field expects.""" - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches @@ -188,14 +196,33 @@ class _Inputter: return value if origin is typing.Union or origin is types.UnionType: - # Currently, the only unions we support are None/Value - # (translated from Optional), which we verified on prep. So - # let's treat this as a simple optional case. + childanntypes = typing.get_args(anntype) if value is None: + if type(None) not in childanntypes: + _raise_type_error( + fieldpath, + type(value), + tuple(_get_origin(c) for c in childanntypes), + ) return None childanntypes_l = [ - c for c in typing.get_args(anntype) if c is not type(None) + c for c in childanntypes if c is not type(None) ] # noqa (pycodestyle complains about *is* with type) + if len(childanntypes_l) > 1: + # A multi-member 'type-disjoint' union; find the member + # matching the value's wire type (prep verified that + # this is decidable). + member = _select_union_member_type(childanntypes_l, value) + if member is None: + _raise_type_error( + fieldpath, + type(value), + tuple(_get_origin(c) for c in childanntypes_l), + ) + return self._value_from_input( + cls, fieldpath, member, value, ioattrs + ) + # Simple Optional case. assert len(childanntypes_l) == 1 return self._value_from_input( cls, fieldpath, childanntypes_l[0], value, ioattrs @@ -510,7 +537,6 @@ class _Inputter: value: Any, ioattrs: IOAttrs | None, ) -> Any: - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-branches if not isinstance(value, dict): @@ -676,7 +702,7 @@ class _Inputter: mttype = _get_multitype_type(anntype, fieldpath, value) # NOTE: We may want to tighten this up; ValueError might be # covering more than the missing enum case we intend here. - except (ValueError, TypeNotPresentError): + except ValueError, TypeNotPresentError: if self._lossy: out = anntype.get_unknown_type_fallback() if out is not None: @@ -696,7 +722,6 @@ class _Inputter: value: Any, ioattrs: IOAttrs | None, ) -> Any: - # pylint: disable=too-many-positional-arguments out: list = [] # Because we are json-centric, we expect a list for all sequences. diff --git a/dist/ba_data/python/efro/dataclassio/_outputter.py b/dist/ba_data/python/efro/dataclassio/_outputter.py index 3273218..5227d82 100644 --- a/dist/ba_data/python/efro/dataclassio/_outputter.py +++ b/dist/ba_data/python/efro/dataclassio/_outputter.py @@ -24,6 +24,7 @@ from efro.dataclassio._base import ( _get_origin, SIMPLE_TYPES, _raise_type_error, + _select_union_member_type, IOExtendedData, IOMultiType, ) @@ -191,13 +192,16 @@ class _Outputter: f' the type-id-storage-name of the IOMulticlass' f' it inherits from.' ) - if self._codec is Codec.HUMAN: - storagename = storagename.replace('_', ' ') - out[storagename] = ( - type_id.name.lower().replace('_', ' ') - if self._codec is Codec.HUMAN - else type_id.value - ) + # If this is the multitype's default type, we skip + # writing the type id; its absence implies the default. + if type_id is not obj.get_default_type_id(): + if self._codec is Codec.HUMAN: + storagename = storagename.replace('_', ' ') + out[storagename] = ( + type_id.name.lower().replace('_', ' ') + if self._codec is Codec.HUMAN + else type_id.value + ) return out @@ -210,7 +214,6 @@ class _Outputter: ioattrs: IOAttrs | None, ) -> Any: # pylint: disable=too-many-statements - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches @@ -227,14 +230,33 @@ class _Outputter: return value if self._create else None if origin is typing.Union or origin is types.UnionType: - # Currently, the only unions we support are None/Value - # (translated from Optional), which we verified on prep. - # So let's treat this as a simple optional case. + childanntypes = typing.get_args(anntype) if value is None: + if type(None) not in childanntypes: + _raise_type_error( + fieldpath, + type(value), + tuple(_get_origin(c) for c in childanntypes), + ) return None childanntypes_l = [ - c for c in typing.get_args(anntype) if c is not type(None) + c for c in childanntypes if c is not type(None) ] # noqa (pycodestyle complains about *is* with type) + if len(childanntypes_l) > 1: + # A multi-member 'type-disjoint' union; find the member + # matching the value's type (prep verified that this is + # decidable). + member = _select_union_member_type(childanntypes_l, value) + if member is None: + _raise_type_error( + fieldpath, + type(value), + tuple(_get_origin(c) for c in childanntypes_l), + ) + return self._process_value( + cls, fieldpath, member, value, ioattrs + ) + # Simple Optional case. assert len(childanntypes_l) == 1 return self._process_value( cls, fieldpath, childanntypes_l[0], value, ioattrs @@ -588,7 +610,6 @@ class _Outputter: value: dict, ioattrs: IOAttrs | None, ) -> Any: - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-branches if not isinstance(value, dict): raise TypeError( diff --git a/dist/ba_data/python/efro/dataclassio/_prep.py b/dist/ba_data/python/efro/dataclassio/_prep.py index a4d8ecb..570b85c 100644 --- a/dist/ba_data/python/efro/dataclassio/_prep.py +++ b/dist/ba_data/python/efro/dataclassio/_prep.py @@ -264,7 +264,6 @@ class PrepSession: recursion_level: int, ) -> None: """Run prep on a dataclass.""" - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches @@ -466,23 +465,72 @@ class PrepSession: ) -> None: """Run prep on a Union type.""" typeargs = typing.get_args(anntype) + + # The simple Optional form (SomeType | None) is always allowed; + # the non-None member can be anything dataclassio supports. if ( - len(typeargs) != 2 - or len([c for c in typeargs if c is type(None)]) != 1 + len(typeargs) == 2 + and len([c for c in typeargs if c is type(None)]) == 1 ): # noqa + for childtype in typeargs: + self.prep_type( + cls, + attrname, + childtype, + None, + recursion_level=recursion_level + 1, + ) + return + + # Anything else must be a 'type-disjoint' union: each member + # must map to a distinct wire type so values can be matched to + # members with no tagging. Members may be str, bool, int OR + # float (not both; they are both numbers on the wire), None, + # and at most one object-shaped type (a dataclass or + # IOMultiType). + seen_number = False + seen_object = False + for childtype in typeargs: + childorigin = _get_origin(childtype) + if childtype is type(None) or childorigin in (str, bool): + continue + if childorigin in (int, float): + if seen_number: + raise TypeError( + f'Union {anntype} for attr \'{attrname}\' on' + f' {cls.__name__} is not supported by dataclassio;' + f' int and float cannot coexist in a union (both' + f' are numbers on the wire).' + ) + seen_number = True + continue + if isinstance(childorigin, type) and ( + dataclasses.is_dataclass(childorigin) + or issubclass(childorigin, IOMultiType) + ): + if seen_object: + raise TypeError( + f'Union {anntype} for attr \'{attrname}\' on' + f' {cls.__name__} is not supported by dataclassio;' + f' only one dataclass or IOMultiType member is' + f' allowed in a union (they are indistinguishable' + f' on the wire).' + ) + seen_object = True + self.prep_type( + cls, + attrname, + childtype, + None, + recursion_level=recursion_level + 1, + ) + continue raise TypeError( f'Union {anntype} for attr \'{attrname}\' on' f' {cls.__name__} is not supported by dataclassio;' - f' only 2 member Unions with one type being None' - f' are supported.' - ) - for childtype in typeargs: - self.prep_type( - cls, - attrname, - childtype, - None, - recursion_level=recursion_level + 1, + f' multi-member unions may contain only str, bool,' + f' int OR float, None, and at most one dataclass or' + f' IOMultiType member (found \'{childtype}\').' ) def prep_enum( diff --git a/dist/ba_data/python/efro/debug.py b/dist/ba_data/python/efro/debug.py index e8f334d..16e6226 100644 --- a/dist/ba_data/python/efro/debug.py +++ b/dist/ba_data/python/efro/debug.py @@ -497,6 +497,14 @@ def _desctype(obj: Any) -> str: if cls is types.MethodType: bnd = 'bound' if hasattr(obj, '__self__') else 'unbound' return f'{bnd} {type(obj).__name__} {obj.__name__}' + if cls is types.FunctionType: + return f'{type(obj).__name__} {obj.__module__}.{obj.__qualname__}' + if cls is types.CellType: + try: + contents = _desctype(obj.cell_contents) + except ValueError: + return f'{type(obj).__name__} (empty)' + return f'{type(obj).__name__} (contains {contents})' return f'{type(obj).__name__}' diff --git a/dist/ba_data/python/efro/logging.py b/dist/ba_data/python/efro/logging.py index eae3290..b78a483 100644 --- a/dist/ba_data/python/efro/logging.py +++ b/dist/ba_data/python/efro/logging.py @@ -471,7 +471,6 @@ class LogHandler(logging.Handler): message: str | logging.LogRecord, labels: dict[str, str], ) -> None: - # pylint: disable=too-many-positional-arguments try: # If they passed a raw record here, bake it down to a string. if isinstance(message, logging.LogRecord): @@ -1025,7 +1024,7 @@ class LogBatchForwarder: self._flush_task.cancel() try: await self._flush_task - except (asyncio.CancelledError, Exception): + except asyncio.CancelledError, Exception: pass self._flush_task = None await self.flush_now() diff --git a/dist/ba_data/python/efro/message/_protocol.py b/dist/ba_data/python/efro/message/_protocol.py index 91b5fa1..4373576 100644 --- a/dist/ba_data/python/efro/message/_protocol.py +++ b/dist/ba_data/python/efro/message/_protocol.py @@ -544,7 +544,6 @@ class MessageProtocol: protocol_module_level_import_code: str | None = None, ) -> str: """Used by create_receiver_module(); do not call directly.""" - # pylint: disable=too-many-positional-arguments import textwrap desc = 'asynchronous' if is_async else 'synchronous' diff --git a/dist/ba_data/python/efro/rpc.py b/dist/ba_data/python/efro/rpc.py index 0872749..66679e7 100644 --- a/dist/ba_data/python/efro/rpc.py +++ b/dist/ba_data/python/efro/rpc.py @@ -391,8 +391,6 @@ class RPCEndpoint: bytes_awaitable: asyncio.Task[bytes], message_id: int, ) -> bytes: - # pylint: disable=too-many-positional-arguments - # We need to know their protocol, so if we haven't gotten a # handshake from them yet, just wait. while self._peer_info is None: diff --git a/dist/ba_data/python/efro/rpcws.py b/dist/ba_data/python/efro/rpcws.py index 8e91882..279d11c 100644 --- a/dist/ba_data/python/efro/rpcws.py +++ b/dist/ba_data/python/efro/rpcws.py @@ -193,8 +193,6 @@ class RPCWSEndpoint: bytes_awaitable: asyncio.Task[bytes], message_id: int, ) -> bytes: - # pylint: disable=too-many-positional-arguments - # Build the wire frame: type(1b) + message_id(2b) + payload. frame = ( _TYPE_MESSAGE.to_bytes(1, _BYTE_ORDER) diff --git a/dist/ba_data/python/efro/threadpool.py b/dist/ba_data/python/efro/threadpool.py index 85c3200..12aa7f9 100644 --- a/dist/ba_data/python/efro/threadpool.py +++ b/dist/ba_data/python/efro/threadpool.py @@ -119,7 +119,7 @@ class ThreadPoolExecutorEx(ThreadPoolExecutor): ' allow_submit_no_wait attr.' ) - key = _callable_name(call) + key = _stable_callable_name(call) with self._no_wait_count_lock: self.no_wait_count += 1 self._no_wait_calls[key] += 1 @@ -161,7 +161,7 @@ class ThreadPoolExecutorEx(ThreadPoolExecutor): def _wrap_timed(self, fn: Callable[P, T]) -> Callable[P, T]: """Wrap ``fn`` to warn on excessive queue-wait / run duration.""" enqueue_time = time.monotonic() - name = _callable_name(fn) + name = _stable_callable_name(fn) def _timed(*args: P.args, **kwargs: P.kwargs) -> T: start = time.monotonic() @@ -259,15 +259,46 @@ class ThreadPoolExecutorEx(ThreadPoolExecutor): strip_exception_tracebacks(exc) -def _callable_name(call: Callable[..., Any]) -> str: - """Best-effort human-readable name for a submitted callable. +def _stable_callable_name(call: Callable[..., Any]) -> str: + """Short, stable, address-free name for a submitted callable. - Unwraps :class:`functools.partial` chains to the underlying function - so diagnostics name the real target, not ``functools.partial``. + Serves two roles: display label in diagnostic warnings, and + aggregation key for the in-flight no-wait call Counter. The second + role is why this extracts a name instead of using ``str()`` or + ``repr()``: + + - For anything but a plain function, ``str()`` embeds a memory + address and/or instance state (``>``), so the same logical callable invoked on N + different objects would fragment into N distinct Counter keys of + count 1, rendering the top-callables report useless. Extracted + names collapse them all to ``Foo.bar`` — the granularity the + diagnostics want. (Addresses are also display noise that varies + per process, hurting log grouping and grepping.) + - ``str()`` of a :class:`functools.partial` (or any wrapper whose + ``__repr__`` shows its stored args) drags arg reprs into the log + line: unbounded length, and in server pools possibly sensitive + data. + + Wrappers are unwrapped to the real target through the two stdlib + conventions: :class:`functools.partial`'s ``func`` attr and the + ``__wrapped__`` attr set by :func:`functools.wraps` (and by + callable wrapper classes such as babase's ``CallStrict``). Without + unwrapping, such wrappers expose no ``__name__`` and would all + collapse into a useless bare wrapper-class name (``partial``, + ``CallStrict``). The ``type(target).__name__`` fallback remains + for wrapper classes that don't participate in either convention. """ target: Any = call - while isinstance(target, functools.partial): - target = target.func + # Depth-capped so a pathological __wrapped__ cycle can't spin. + for _ in range(10): + if isinstance(target, functools.partial): + target = target.func + else: + wrapped = getattr(target, '__wrapped__', None) + if wrapped is None: + break + target = wrapped return ( getattr(target, '__qualname__', None) or getattr(target, '__name__', None) diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index df52dfc..edd14b1 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ