mirror of
https://github.com/imayushsaini/Bombsquad-Ballistica-Modded-Server.git
synced 2026-08-15 13:04:30 +00:00
ballistica update ; assets , party password
This commit is contained in:
parent
a2d7d72d8a
commit
17a11b9cef
87 changed files with 6967 additions and 1627 deletions
8
dist/ba_data/python/babase/__init__.py
vendored
8
dist/ba_data/python/babase/__init__.py
vendored
|
|
@ -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',
|
||||
|
|
|
|||
135
dist/ba_data/python/babase/_assetsubsystem.py
vendored
135
dist/ba_data/python/babase/_assetsubsystem.py
vendored
|
|
@ -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 + ``<role>.<format>`` 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/<locale>`` 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/<locale.value>``,
|
||||
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 ``<role>.<format>`` 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
|
||||
|
||||
|
|
|
|||
14
dist/ba_data/python/babase/_constructmode.py
vendored
14
dist/ba_data/python/babase/_constructmode.py
vendored
|
|
@ -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).
|
||||
|
|
|
|||
3
dist/ba_data/python/babase/_env.py
vendored
3
dist/ba_data/python/babase/_env.py
vendored
|
|
@ -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():
|
||||
|
|
|
|||
46
dist/ba_data/python/babase/_general.py
vendored
46
dist/ba_data/python/babase/_general.py
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
38
dist/ba_data/python/babase/_hooks.py
vendored
38
dist/ba_data/python/babase/_hooks.py
vendored
|
|
@ -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/<locale>`` 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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
92
dist/ba_data/python/babase/_language.py
vendored
92
dist/ba_data/python/babase/_language.py
vendored
|
|
@ -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(
|
||||
|
|
|
|||
62
dist/ba_data/python/babase/_locale.py
vendored
62
dist/ba_data/python/babase/_locale.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
dist/ba_data/python/babase/_stringedit.py
vendored
3
dist/ba_data/python/babase/_stringedit.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
84
dist/ba_data/python/babase/_text.py
vendored
84
dist/ba_data/python/babase/_text.py
vendored
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
dist/ba_data/python/baclassic/_appmode.py
vendored
68
dist/ba_data/python/baclassic/_appmode.py
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
122
dist/ba_data/python/baclassic/_clienteffect.py
vendored
122
dist/ba_data/python/baclassic/_clienteffect.py
vendored
|
|
@ -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 '<apverid>:<name>' 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
|
||||
|
|
|
|||
1
dist/ba_data/python/baclassic/_net.py
vendored
1
dist/ba_data/python/baclassic/_net.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
dist/ba_data/python/baclassic/_servermode.py
vendored
1
dist/ba_data/python/baclassic/_servermode.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
dist/ba_data/python/bacommon/app.py
vendored
7
dist/ba_data/python/bacommon/app.py
vendored
|
|
@ -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'
|
||||
|
|
|
|||
22
dist/ba_data/python/bacommon/assetcas.py
vendored
22
dist/ba_data/python/bacommon/assetcas.py
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}.')
|
||||
|
|
|
|||
14
dist/ba_data/python/bacommon/bacloud.py
vendored
14
dist/ba_data/python/bacommon/bacloud.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
82
dist/ba_data/python/bacommon/clienteffect.py
vendored
82
dist/ba_data/python/bacommon/clienteffect.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
6
dist/ba_data/python/bacommon/cloud.py
vendored
6
dist/ba_data/python/bacommon/cloud.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
29
dist/ba_data/python/bacommon/cloudfilecodec.py
vendored
29
dist/ba_data/python/bacommon/cloudfilecodec.py
vendored
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
79
dist/ba_data/python/bacommon/docui/v1.py
vendored
79
dist/ba_data/python/bacommon/docui/v1.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
223
dist/ba_data/python/bacommon/docui/v2.py
vendored
223
dist/ba_data/python/bacommon/docui/v2.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
48
dist/ba_data/python/bacommon/langstr/__init__.py
vendored
48
dist/ba_data/python/bacommon/langstr/__init__.py
vendored
|
|
@ -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',
|
||||
|
|
|
|||
42
dist/ba_data/python/bacommon/langstr/_blob.py
vendored
42
dist/ba_data/python/bacommon/langstr/_blob.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
635
dist/ba_data/python/bacommon/langstr/_core.py
vendored
635
dist/ba_data/python/bacommon/langstr/_core.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
dist/ba_data/python/bacommon/langstr/_wrapper.py
vendored
48
dist/ba_data/python/bacommon/langstr/_wrapper.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
19
dist/ba_data/python/bacommon/loctext.py
vendored
19
dist/ba_data/python/bacommon/loctext.py
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
293
dist/ba_data/python/bacommon/workspace/assetsv1.py
vendored
293
dist/ba_data/python/bacommon/workspace/assetsv1.py
vendored
|
|
@ -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 ``<name>.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
|
||||
(``{@<apref-logical-path>:<entry-path>}``).
|
||||
"""
|
||||
|
||||
@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
|
||||
#: (``<account>.<package>.<version-segment>``). 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 = '<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
|
||||
``<NOT-TRANSLATED>`` 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:
|
||||
|
|
|
|||
45
dist/ba_data/python/bacommontools/streamws.py
vendored
45
dist/ba_data/python/bacommontools/streamws.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
34
dist/ba_data/python/baenv.py
vendored
34
dist/ba_data/python/baenv.py
vendored
|
|
@ -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:
|
||||
|
|
|
|||
15
dist/ba_data/python/bascenev1/__init__.py
vendored
15
dist/ba_data/python/bascenev1/__init__.py
vendored
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
4
dist/ba_data/python/bascenev1/_hooks.py
vendored
4
dist/ba_data/python/bascenev1/_hooks.py
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
340
dist/ba_data/python/bascenev1/_net.py
vendored
340
dist/ba_data/python/bascenev1/_net.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
dist/ba_data/python/bascenev1/_profile.py
vendored
4
dist/ba_data/python/bascenev1/_profile.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
26
dist/ba_data/python/bascenev1/builtinassets.py
vendored
26
dist/ba_data/python/bascenev1/builtinassets.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
dist/ba_data/python/bascenev1/stdassets.py
vendored
24
dist/ba_data/python/bascenev1/stdassets.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]] = []
|
||||
|
|
|
|||
2
dist/ba_data/python/bauiv1/__init__.py
vendored
2
dist/ba_data/python/bauiv1/__init__.py
vendored
|
|
@ -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',
|
||||
|
|
|
|||
43
dist/ba_data/python/bauiv1/_appsubsystem.py
vendored
43
dist/ba_data/python/bauiv1/_appsubsystem.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
43
dist/ba_data/python/bauiv1/_assetref.py
vendored
43
dist/ba_data/python/bauiv1/_assetref.py
vendored
|
|
@ -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':
|
||||
|
|
|
|||
551
dist/ba_data/python/bauiv1/_docuiv2testassets.py
vendored
Normal file
551
dist/ba_data/python/bauiv1/_docuiv2testassets.py
vendored
Normal file
|
|
@ -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')
|
||||
8
dist/ba_data/python/bauiv1/_uitypes.py
vendored
8
dist/ba_data/python/bauiv1/_uitypes.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
333
dist/ba_data/python/bauiv1/builtinassets.py
vendored
333
dist/ba_data/python/bauiv1/builtinassets.py
vendored
|
|
@ -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')
|
||||
|
|
|
|||
242
dist/ba_data/python/bauiv1/stdassets.py
vendored
242
dist/ba_data/python/bauiv1/stdassets.py
vendored
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
dist/ba_data/python/bauiv1lib/docui/__init__.py
vendored
21
dist/ba_data/python/bauiv1lib/docui/__init__.py
vendored
|
|
@ -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
|
||||
|
|
|
|||
232
dist/ba_data/python/bauiv1lib/docui/_controller.py
vendored
232
dist/ba_data/python/bauiv1lib/docui/_controller.py
vendored
|
|
@ -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()
|
||||
|
|
|
|||
285
dist/ba_data/python/bauiv1lib/docui/_resolve.py
vendored
Normal file
285
dist/ba_data/python/bauiv1lib/docui/_resolve.py
vendored
Normal file
|
|
@ -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
|
||||
41
dist/ba_data/python/bauiv1lib/docui/_window.py
vendored
41
dist/ba_data/python/bauiv1lib/docui/_window.py
vendored
|
|
@ -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,
|
||||
|
|
|
|||
45
dist/ba_data/python/bauiv1lib/docui/prep/__init__.py
vendored
Normal file
45
dist/ba_data/python/bauiv1lib/docui/prep/__init__.py
vendored
Normal file
|
|
@ -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',
|
||||
]
|
||||
794
dist/ba_data/python/bauiv1lib/docui/prep/_calls.py
vendored
Normal file
794
dist/ba_data/python/bauiv1lib/docui/prep/_calls.py
vendored
Normal file
|
|
@ -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()
|
||||
640
dist/ba_data/python/bauiv1lib/docui/prep/_calls2.py
vendored
Normal file
640
dist/ba_data/python/bauiv1lib/docui/prep/_calls2.py
vendored
Normal file
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
71
dist/ba_data/python/bauiv1lib/docui/prep/_types.py
vendored
Normal file
71
dist/ba_data/python/bauiv1lib/docui/prep/_types.py
vendored
Normal file
|
|
@ -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]]
|
||||
1692
dist/ba_data/python/bauiv1lib/docuitest.py
vendored
1692
dist/ba_data/python/bauiv1lib/docuitest.py
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
147
dist/ba_data/python/bauiv1lib/inventory.py
vendored
147
dist/ba_data/python/bauiv1lib/inventory.py
vendored
|
|
@ -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 ``<apverid>:<name>`` 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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
128
dist/ba_data/python/bauiv1lib/passwordprompt.py
vendored
Normal file
128
dist/ba_data/python/bauiv1lib/passwordprompt.py
vendored
Normal file
|
|
@ -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)
|
||||
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
42
dist/ba_data/python/efro/cloudshell.py
vendored
42
dist/ba_data/python/efro/cloudshell.py
vendored
|
|
@ -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('/')
|
||||
|
|
|
|||
77
dist/ba_data/python/efro/dataclassio/_base.py
vendored
77
dist/ba_data/python/efro/dataclassio/_base.py
vendored
|
|
@ -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}'."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
74
dist/ba_data/python/efro/dataclassio/_prep.py
vendored
74
dist/ba_data/python/efro/dataclassio/_prep.py
vendored
|
|
@ -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(
|
||||
|
|
|
|||
8
dist/ba_data/python/efro/debug.py
vendored
8
dist/ba_data/python/efro/debug.py
vendored
|
|
@ -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__}'
|
||||
|
||||
|
||||
|
|
|
|||
3
dist/ba_data/python/efro/logging.py
vendored
3
dist/ba_data/python/efro/logging.py
vendored
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
2
dist/ba_data/python/efro/rpc.py
vendored
2
dist/ba_data/python/efro/rpc.py
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
dist/ba_data/python/efro/rpcws.py
vendored
2
dist/ba_data/python/efro/rpcws.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
47
dist/ba_data/python/efro/threadpool.py
vendored
47
dist/ba_data/python/efro/threadpool.py
vendored
|
|
@ -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 (``<bound method Foo.bar of <Foo
|
||||
object at 0x...>>``), 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)
|
||||
|
|
|
|||
BIN
dist/bombsquad_headless
vendored
BIN
dist/bombsquad_headless
vendored
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue