# Released under the MIT License. See LICENSE for details. # """Cloud related functionality. .. warning:: This is an internal api and subject to change at any time. Do not use it in mod code. """ from enum import Enum from dataclasses import dataclass, field from typing import TYPE_CHECKING, Annotated, override from efro.message import Message, Response from efro.dataclassio import ioprepped, IOAttrs from bacommon.analytics import AnalyticsEvent from bacommon import securedata from bacommon.transfer import DirectoryManifest from bacommon.locale import Locale from bacommon.login import LoginType from bacommon.docui import DocUIRequest, DocUIResponse import bacommon.displayitem as ditm import bacommon.clienteffect as clfx if TYPE_CHECKING: pass class WebLocation(Enum): """Set of places we can be directed on ballistica.net.""" ACCOUNT_EDITOR = 'e' ACCOUNT_DELETE_SECTION = 'd' @ioprepped @dataclass class CloudVals: """Engine config values provided by the master server. Used to convey things such as debug logging. """ #: Fully qualified type names we should emit extra debug logs for #: when garbage-collected (for debugging ref loops). gc_debug_types: Annotated[ list[str], IOAttrs('gct', store_default=False) ] = field(default_factory=list) #: Max number of objects of a given type to emit debug logs for. gc_debug_type_limit: Annotated[int, IOAttrs('gdl', store_default=False)] = 2 @ioprepped @dataclass class LoginProxyRequestMessage(Message): """Request send to the cloud to ask for a login-proxy.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [LoginProxyRequestResponse] @ioprepped @dataclass class LoginProxyRequestResponse(Response): """Response to a request for a login proxy.""" # URL to direct the user to for sign in. url: Annotated[str, IOAttrs('u')] # URL to use for overlay-web-browser sign ins. url_overlay: Annotated[str, IOAttrs('uo')] # Proxy-Login id for querying results. proxyid: Annotated[str, IOAttrs('p')] # Proxy-Login key for querying results. proxykey: Annotated[str, IOAttrs('k')] @ioprepped @dataclass class LoginProxyStateQueryMessage(Message): """Soo.. how is that login proxy going?""" proxyid: Annotated[str, IOAttrs('p')] proxykey: Annotated[str, IOAttrs('k')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [LoginProxyStateQueryResponse] @ioprepped @dataclass class LoginProxyStateQueryResponse(Response): """Here's the info on that login-proxy you asked about, boss.""" class State(Enum): """States a login-proxy can be in.""" WAITING = 'waiting' SUCCESS = 'success' FAIL = 'fail' state: Annotated[State, IOAttrs('s')] # On success, these will be filled out. credentials: Annotated[str | None, IOAttrs('tk')] @ioprepped @dataclass class LoginProxyCompleteMessage(Message): """Just so you know, we're done with this proxy.""" proxyid: Annotated[str, IOAttrs('p')] @ioprepped @dataclass class PingMessage(Message): """Standard ping.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [PingResponse] @ioprepped @dataclass class PingResponse(Response): """pong.""" @ioprepped @dataclass class TestMessage(Message): """Can I get some of that workspace action?""" testfoo: Annotated[int, IOAttrs('f')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [TestResponse] @ioprepped @dataclass class TestResponse(Response): """Here's that workspace you asked for, boss.""" testfoo: Annotated[int, IOAttrs('f')] @ioprepped @dataclass class WorkspaceFetchState: """Common state data for a workspace fetch.""" manifest: Annotated[DirectoryManifest, IOAttrs('m')] iteration: Annotated[int, IOAttrs('i')] = 0 total_deletes: Annotated[int, IOAttrs('tdels')] = 0 total_downloads: Annotated[int, IOAttrs('tdlds')] = 0 total_up_to_date: Annotated[int | None, IOAttrs('tunmd')] = None @ioprepped @dataclass class WorkspaceFetchMessage(Message): """Can I get some of that workspace action?""" workspaceid: Annotated[str, IOAttrs('w')] state: Annotated[WorkspaceFetchState, IOAttrs('s')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [WorkspaceFetchResponse] @ioprepped @dataclass class WorkspaceFetchResponse(Response): """Here's that workspace you asked for, boss.""" state: Annotated[WorkspaceFetchState, IOAttrs('s')] deletes: Annotated[list[str], IOAttrs('dlt', store_default=False)] = field( default_factory=list ) downloads_inline: Annotated[ dict[str, bytes], IOAttrs('dinl', store_default=False) ] = field(default_factory=dict) done: Annotated[bool, IOAttrs('d')] = False #: If set, the client should treat the sync as failed and display #: this message. Allows the server to communicate user-facing errors #: without relying on the protocol's ``forward_clean_errors`` flag. error: Annotated[ str | None, IOAttrs('e', soft_default=None, store_default=False) ] = None @ioprepped @dataclass class MerchAvailabilityMessage(Message): """Can we show merch link?""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [MerchAvailabilityResponse] @ioprepped @dataclass class MerchAvailabilityResponse(Response): """About that merch...""" url: Annotated[str | None, IOAttrs('u')] @ioprepped @dataclass class SignInMessage(Message): """Can I sign in please?""" login_type: Annotated[LoginType, IOAttrs('l')] sign_in_token: Annotated[str, IOAttrs('t')] # For debugging. Can remove soft_default once build 20988+ is ubiquitous. description: Annotated[str, IOAttrs('d', soft_default='-')] apptime: Annotated[float, IOAttrs('at', soft_default=-1.0)] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [SignInResponse] @ioprepped @dataclass class SignInResponse(Response): """Here's that sign-in result you asked for, boss.""" credentials: Annotated[str | None, IOAttrs('c')] @ioprepped @dataclass class ManageAccountMessage(Message): """Message asking for a manage-account url.""" weblocation: Annotated[WebLocation, IOAttrs('l')] = ( WebLocation.ACCOUNT_EDITOR ) @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ManageAccountResponse] @ioprepped @dataclass class ManageAccountResponse(Response): """Here's that sign-in result you asked for, boss.""" url: Annotated[str | None, IOAttrs('u')] @ioprepped @dataclass class StoreQueryMessage(Message): """Message asking about purchasable stuff and store related state.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [StoreQueryResponse] @ioprepped @dataclass class StoreQueryResponse(Response): """Here's that store info you asked for, boss.""" class Result(Enum): """Our overall result.""" SUCCESS = 's' ERROR = 'e' @dataclass class Purchase: """Info about a purchasable thing.""" purchaseid: Annotated[str, IOAttrs('id')] # Overall result; all data is undefined if not SUCCESS. result: Annotated[Result, IOAttrs('r')] tokens: Annotated[int, IOAttrs('t')] gold_pass: Annotated[bool, IOAttrs('g')] available_purchases: Annotated[list[Purchase], IOAttrs('p')] token_info_url: Annotated[str, IOAttrs('tiu')] @ioprepped @dataclass class SecureDataCheckerRequest(Message): """Can I get a checker over here?.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [SecureDataCheckerResponse] @ioprepped @dataclass class SecureDataCheckerResponse(Response): """Here's that checker ya asked for, boss.""" checker: Annotated[securedata.Reader, IOAttrs('c')] @ioprepped @dataclass class SecureDataSigningTestRequest(Message): """Ask basn to sign a test payload two ways for client verification. Test-only round-trip used to confirm that ed25519 verify (``_babase.verify_ed25519`` in the app binary; ``cryptography`` fallback in pytest) accepts both master-signed and delegate-signed payloads against the embedded :data:`~bacommon.securedata.STATIC_DATA_PUBLIC_KEYS`. basn handles this without forwarding to bamaster. """ @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [SecureDataSigningTestResponse] @ioprepped @dataclass class SecureDataSigningTestResponse(Response): """Master- and delegate-signed archives over the same payload. The verifier recovers the original payload via :meth:`~bacommon.securedata.Reader.read` regardless of which signing flow produced the archive. """ #: Archive built with basn's cached static-data master key #: (no cert). master_archive: Annotated[securedata.Archive, IOAttrs('m')] #: Archive built with basn's delegated #: :class:`~bacommon.securedata.Writer` (carries a #: master-signed cert). delegate_archive: Annotated[securedata.Archive, IOAttrs('d')] @ioprepped @dataclass class ResolvedFlavorManifest: """A resolved flavor-manifest blob, delivered inline (Tier 1). Client-facing counterpart to the basn↔master ``basntobamaster.ResolvedFlavorManifest``; basn relays it unchanged when returning a resolved manifest to the client. The flavor-manifest's canonical JSON bytes travel inline in :attr:`data` (they are small — a ``logical_path -> {hash, size}`` map). The client writes them verbatim into its CAS store under :attr:`hash` (sha256-verified), references that hash from its top-level cache manifest exactly as the bundled manifest does, and parses :attr:`data` (the ``{"e": {logical_path: {"h", "s"}}}`` shape) to discover the data-blob hashes to fetch via ``GET /casblob/{hash}`` (Tier 2). """ #: sha256 hex of :attr:`data` — the flavor-manifest's CAS hash. hash: Annotated[str, IOAttrs('h')] #: Canonical flavor-manifest JSON bytes (stored verbatim client-side #: as a CAS blob; its sha256 equals :attr:`hash`). data: Annotated[bytes, IOAttrs('d')] class AssetPackageResolveError(Enum): """Why an asset-package resolve failed (structured for client branching). Travels back to the client on :class:`ResolveAssetPackageResponse` so the runtime can react precisely (e.g. prompt for sign-in on ``AUTH_REQUIRED``) rather than parsing the human-readable ``error`` string. """ #: Caller is unauthenticated and the version is non-public; signing in #: with an account that has access may resolve it. AUTH_REQUIRED = 'auth' #: Caller is authenticated but lacks access to this (non-public) #: version (not the owner / not on the package's dev team). ACCESS_DENIED = 'access' #: The requested asset-package-version id is unknown / invalid. NOT_FOUND = 'notfound' #: A requested dimension value was invalid (texture profile/quality, #: language, etc.). INVALID = 'invalid' #: An internal/assemble error occurred server-side. INTERNAL = 'internal' #: The client build is too old to address current asset-package #: manifests (which use clean source-named logical paths); the user #: 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): """Coarse phase of an in-progress server-side asset-package build. Client-facing and deliberately decoupled from internal cloud-build state — the master translates its build status into this. Combined with the optional counts on :class:`AssetPackageBuildProgress` it lets the client render a localized progress message (today English only; structured so it can be translated later). """ #: Spinning up / queued — the build hasn't begun real work yet. PREPARING = 'prep' #: Building the package's constituent assets. BUILDING = 'build' #: Assembling built outputs into the final manifest. FINALIZING = 'final' @ioprepped @dataclass class AssetPackageBuildProgress: """Progress of a server-side asset-package build, for the client. Returned on :class:`ResolveAssetPackageResponse` when a resolve can't be satisfied immediately because the master is (re)building the requested flavors (a build can take noticeably longer than a plain download). The client renders this and re-sends the same resolve to poll until the manifest is ready. Intentionally a resolve-specific, client-facing type — it does **not** expose internal cloud-build types. The master translates its build status into this at the resolve boundary. """ #: Coarse phase, for a phase-appropriate (localized) message. phase: Annotated[AssetPackageBuildPhase, IOAttrs('p')] #: Optional 'done of total' unit counts (e.g. assets built) for a #: progress readout. Typically both set or both unset. units_done: Annotated[int | None, IOAttrs('ud', store_default=False)] = None units_total: Annotated[int | None, IOAttrs('ut', store_default=False)] = ( None ) #: Optional free-form text shown as-is (untranslated) — an escape #: hatch for custom/modder build status the fixed phases can't #: express. detail: Annotated[str | None, IOAttrs('d', store_default=False)] = None @ioprepped @dataclass class ResolveAssetPackageMessage(Message): """Resolve an asset-package-version's manifest for download (Tier 1). Sent by a client to the basn node it is connected to when it needs to download an asset package. basn resolves the manifest via bamaster on the client's behalf and returns, per bucket, the resolved flavor-manifest blob (its CAS hash + canonical bytes, inline) plus a short-lived capability token. The client then fetches each data blob the flavor-manifests reference from the same node via ``GET /casblob/{hash}`` (Tier 2), presenting the token. Texture dimensions travel as plain strings (the ``TextureProfile`` / ``TextureTier`` enum values) so this module stays decoupled from the master-only ``baserver.workspace.assetsv1``; the master converts + validates them. The requesting account is conveyed via the standard account-session-channel sidecar that basn auto-attaches to every client message (no explicit field needed): a signed-in client resolves against its account (required for non-public DEV/TEST versions), while an anonymous client resolves PROD versions only (which are public). """ #: Fully-qualified ``account.package.version`` id to resolve. apverid: Annotated[str, IOAttrs('a')] #: Chosen locale for the ``language`` bucket. language: Annotated[Locale, IOAttrs('l')] #: Chosen ``TextureProfile`` value (e.g. ``'fallback_v1'``). texture_profile: Annotated[str, IOAttrs('tp')] #: Chosen ``TextureTier`` value (e.g. ``'regular'``). Wire key stays #: the historical ``'tq'`` (it predates the tier/quality rename) so #: this message stays compatible with un-migrated basn nodes and #: older clients — construct-mode asset resolve sends it on every #: boot, so the key must not break across versions. texture_tier: Annotated[str, IOAttrs('tq')] #: The client's engine build number. basn relays it to master, which #: gates the resolve on it (clients too old to address current #: source-named manifests get a ``CLIENT_TOO_OLD`` error). Soft- #: defaults to 0 so older clients (and un-migrated basn) read as #: build 0 -- always below the floor. build_number: Annotated[int, IOAttrs('bn', soft_default=0)] = 0 @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ResolveAssetPackageResponse] @ioprepped @dataclass class ResolveAssetPackageResponse(Response): """Resolved flavor-manifests + capability token for a download. ``buckets`` maps a ``bucket/flavor`` coordinate (e.g. ``'textures/fallback_v1.gamma.regular'``, ``'constant'``) to that flavor's resolved flavor-manifest blob (CAS hash + canonical bytes, delivered inline). On failure ``error`` carries a human-readable message and ``buckets`` is empty / ``token`` is ``None``. """ #: Human-readable failure message (bad apverid, access denied, bad #: dimension value, assemble failure), or ``None`` on success. error: Annotated[str | None, IOAttrs('e')] #: Structured failure reason accompanying ``error`` (lets the client #: branch — e.g. prompt for sign-in — without parsing the message). #: ``None`` on success, or when an older server didn't supply one. #: ``enum_fallback`` makes a *future* unrecognized code degrade to #: ``INTERNAL`` (message decode is lossy) instead of failing the whole #: response — so a new error reason never bricks an older client's #: resolve; it just surfaces the human ``error`` under a generic code. error_code: Annotated[ AssetPackageResolveError | None, IOAttrs( 'ec', soft_default=None, enum_fallback=AssetPackageResolveError.INTERNAL, ), ] #: ``bucket/flavor`` coordinate -> the resolved flavor-manifest blob #: for that flavor. Empty when ``error`` is set. buckets: Annotated[dict[str, ResolvedFlavorManifest], IOAttrs('b')] #: Short-lived capability token the client presents to #: ``GET /casblob/{hash}`` to fetch the resolved blobs. ``None`` #: when ``error`` is set. token: Annotated[securedata.Archive | None, IOAttrs('tok')] #: Set when the manifest isn't ready yet because the master is #: building the requested flavors; the client renders this progress #: and re-sends the same resolve to poll. ``None`` once resolved #: (then ``buckets`` / ``token`` are populated) or on ``error``. build_progress: Annotated[ AssetPackageBuildProgress | None, IOAttrs('bp', soft_default=None) ] @ioprepped @dataclass class CloudValsRequest(Message): """Can a fella get some cloud vals around here?.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [CloudValsResponse] @ioprepped @dataclass class CloudValsResponse(Response): """Here's them cloud vals ya asked for, boss.""" vals: Annotated[CloudVals, IOAttrs('v')] @ioprepped @dataclass class ChestActionMessage(Message): """Request action about a chest.""" class Action(Enum): """Types of actions we can request.""" # Unlocking (for free or with tokens). UNLOCK = 'u' # Watched an ad to reduce wait. AD = 'ad' action: Annotated[Action, IOAttrs('a')] # Tokens we are paying (only applies to unlock). token_payment: Annotated[int, IOAttrs('t')] chest_id: Annotated[str, IOAttrs('i')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ChestActionResponse] @ioprepped @dataclass class ChestActionResponse(Response): """Here's the results of that action you asked for, boss.""" # Tokens that were actually charged. tokens_charged: Annotated[int, IOAttrs('t')] = 0 # If present, signifies the chest has been opened and we should show # the user this stuff that was in it. contents: Annotated[list[ditm.Wrapper] | None, IOAttrs('c')] = None # If contents are present, which of the chest's prize-sets they # represent. prizeindex: Annotated[int, IOAttrs('i')] = 0 # Printable error if something goes wrong. error: Annotated[str | None, IOAttrs('e')] = None # Printable warning. Shown in orange with an error sound. Does not # mean the action failed; only that there's something to tell the # users such as 'It looks like you are faking ad views; stop it or # you won't have ad options anymore.' warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None # Printable success message. Shown in green with a cash-register # sound. Can be used for things like successful wait reductions via # ad views. Used in builds earlier than 22311; can remove once # 22311+ is ubiquitous. success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None # Effects to show on the client. Replaces warning and success_msg in # build 22311 or newer. effects: Annotated[ list[clfx.Effect], IOAttrs('fx', store_default=False) ] = field(default_factory=list) @ioprepped @dataclass class FulfillDocUIRequest(Message): """Can a fella get a doc-ui round here?""" request: Annotated[DocUIRequest, IOAttrs('r')] domain: Annotated[str, IOAttrs('d')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [FulfillDocUIResponse] @ioprepped @dataclass class FulfillDocUIResponse(Response): """Here's that doc-ui you asked for, boss.""" response: Annotated[DocUIResponse, IOAttrs('r')] @ioprepped @dataclass class AnalyticsEventMessage(Message): """Have a nice analytics event!""" event: Annotated[AnalyticsEvent, IOAttrs('e')] @ioprepped @dataclass class AuthRequestMessage(Message): """Request access to a server for a current account.""" global_app_instance_uuid: Annotated[str, IOAttrs('a')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [AuthRequestResponse] @ioprepped @dataclass class AuthRequestResponse(Response): """Here's that access ya asked for boss.""" error: Annotated[str | None, IOAttrs('e')] token: Annotated[str | None, IOAttrs('t')] @ioprepped @dataclass class TransientAPIKeyRequest(Message): """Request a transient API key for the currently signed-in account.""" @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [TransientAPIKeyResponse] @ioprepped @dataclass class TransientAPIKeyResponse(Response): """Response to a transient API key request.""" class Error(Enum): """Failure modes.""" INTERNAL_ERROR = 'ie' KEY_LIMIT_REACHED = 'klr' key: Annotated[str | None, IOAttrs('k')] error: Annotated[Error | None, IOAttrs('e')]