ballistica update ; assets , party password

This commit is contained in:
Ayush Saini 2026-07-22 16:32:40 +05:30
parent a2d7d72d8a
commit 17a11b9cef
87 changed files with 6967 additions and 1627 deletions

View file

@ -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',

View file

@ -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

View file

@ -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).

View file

@ -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():

View file

@ -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

View file

@ -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
)

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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.

View file

@ -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',
]

View file

@ -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.

View file

@ -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}.')

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

@ -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(

View file

@ -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',
]

View file

@ -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

View file

@ -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)

View file

@ -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',

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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:

View file

@ -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',

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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']),

View file

@ -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

View file

@ -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]]] = []

View file

@ -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',

View file

@ -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

View file

@ -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':

View 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')

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -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},
),

View file

@ -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

View file

@ -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

View file

@ -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()

View 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

View file

@ -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,

View 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',
]

View 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()

View 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,
)
)

View 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]]

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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,
),

View 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)

View file

@ -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',
),

View file

@ -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',
),

View file

@ -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,
),

View file

@ -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.

View file

@ -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,

View file

@ -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('/')

View file

@ -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}'."
)

View file

@ -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.

View file

@ -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(

View file

@ -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(

View file

@ -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__}'

View file

@ -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()

View file

@ -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'

View file

@ -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:

View file

@ -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)

View file

@ -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)

Binary file not shown.