@umicat/platform-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @umicat/platform-sdk
2
+
3
+ The engine-neutral half of the Umicat game SDK. Identity, cloud saves, shared
4
+ game data, multiplayer rooms, runtime AI, voice input and scripted dialogue —
5
+ none of which care how pixels get on screen.
6
+
7
+ **Status: extracted and verified, not yet adopted.** `@umicat/phaser-sdk` still
8
+ ships its own copies; the swap is proven safe by `scripts/verify-swap.mjs` but
9
+ has not been applied, because applying it requires publishing this package to
10
+ npm first. Nothing in production consumes this yet.
11
+
12
+ ## Why it exists
13
+
14
+ `@umicat/phaser-sdk` is ~19.4k LOC, of which roughly four fifths is the scene
15
+ system and the editor — genuinely Phaser-shaped. The rest was already written
16
+ without touching Phaser, and it is the part every runtime needs: a 3D SDK, the
17
+ iOS and Android clients, anything we build next.
18
+
19
+ The one thing keeping it tangled was the composition root, which constructed a
20
+ Phaser dialogue box. That is now injected, so `UmicatCore` is a platform with no
21
+ opinion about rendering, and a runtime subclasses it to add what needs a canvas.
22
+
23
+ See ADR-033 in `umicat-design`.
24
+
25
+ ```ts
26
+ // A runtime wires the platform in, then adds its own engine-shaped modules.
27
+ import { UmicatCore } from '@umicat/platform-sdk';
28
+
29
+ class ThreeUmicat extends UmicatCore {
30
+ static async init(options = {}) {
31
+ return new ThreeUmicat(await UmicatCore.connect(MY_SDK_VERSION, options));
32
+ }
33
+ }
34
+
35
+ const umicat = await ThreeUmicat.init();
36
+ await umicat.saves.set('progress', { level: 3 });
37
+ const reply = await umicat.ai.complete('Greet the player by name');
38
+ ```
39
+
40
+ ## What is here, and what deliberately is not
41
+
42
+ | in | out |
43
+ |---|---|
44
+ | `UmicatCore` — transports, handshake, identity | anything that draws |
45
+ | `saves`, `gameData`, `rooms`, `ai`, `platform`, `voice` | the scene system, prefabs, HUD, tilemaps |
46
+ | `DialogueModule` + `DialogueRunner`, renderer injected | `DialogueBox` (Phaser), screenshot/recording (take a `Phaser.Game`) |
47
+ | the platform/RPC half of `protocol.ts` | the editor + tilemap message half |
48
+
49
+ ## Verifying a change
50
+
51
+ ```bash
52
+ npm test # engine-neutrality contracts + dialogue behaviour
53
+ node scripts/verify-swap.mjs # proves @umicat/phaser-sdk still builds, exports
54
+ # an identical API, and passes its whole suite
55
+ ```
56
+
57
+ `verify-swap.mjs` copies both repos to a scratch dir, rewires the copy, and
58
+ asserts the 105 exported names are unchanged and the Phaser SDK's 39 unit tests
59
+ plus 3 browser tests still pass. **It never writes to either real repo.**
60
+
61
+ ## Two traps worth knowing
62
+
63
+ **Deep imports, not the barrel.** The shims `apply-swap.mjs` writes point at
64
+ `@umicat/platform-sdk/saves/SavesModule.js`, not at the package root. A barrel
65
+ re-export would pull the whole index — and with it the realtime module — into
66
+ every file that merely wanted `Transport`, silently changing the module graph of
67
+ every game. Colyseus is lazily imported (`await import('colyseus.js')`), and a
68
+ test asserts the package has **zero** static third-party imports so it stays
69
+ that way.
70
+
71
+ **Explicit `.js` in specifiers.** `moduleResolution: bundler` lets you write
72
+ `@umicat/platform-sdk/dialogue/DialogueModule`, and tsc emits that string verbatim.
73
+ Bundlers cope; a browser loading `dist/` raw — which the Phaser SDK's own
74
+ browser tests do — gets a 404. Hence `.js` everywhere and both subpath patterns
75
+ in `exports`.
76
+
77
+ ## Provenance
78
+
79
+ Every module here was copied from `umicat-phaser-sdk/src` at v1.0.90 and, except
80
+ where noted, is byte-identical. The changes were: `UmicatCore` (dialogue no
81
+ longer constructed in the composition root), `DialogueModule` (renderer injected,
82
+ generic over scene/theme), and `protocol.ts` (split at the editor boundary).
@@ -0,0 +1,62 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ import type { AiActParams, AiActResult, AiCompleteParams, AiCompleteResult, AiActionDef } from '../protocol.js';
3
+ /**
4
+ * Runtime AI (ADR-017) — let the game call an in-game LLM at play time
5
+ * ("living NPCs" / AI opponents). The AI is a *virtual player*: it can only
6
+ * TALK (`say`) and CHOOSE an action (`do`) from the vocabulary the game
7
+ * declares — it never mutates game state, which stays the game's job.
8
+ *
9
+ * `act()` always RESOLVES with a structured {@link AiActResult} — never throws —
10
+ * so the game can branch in-fiction on `{ ok:false, reason }` (e.g. prompt an
11
+ * anonymous player to sign in on `SIGN_IN_REQUIRED`). The player is billed.
12
+ */
13
+ export declare class AiModule {
14
+ private transport;
15
+ constructor(transport: Transport);
16
+ act(params: AiActParams): Promise<AiActResult>;
17
+ /**
18
+ * Generic ONE-SHOT completion — a raw prompt→text LLM call (ADR-028), the utility
19
+ * sibling of {@link act}. The game supplies the whole prompt and decides what the text
20
+ * is for (summarize / classify / name-generate / score / …); the platform stays
21
+ * task-neutral (auth + credits + safety rails + passthrough). No persona / actions /
22
+ * history — for in-character dialogue use `npc().say()` instead.
23
+ *
24
+ * Like {@link act}, it always RESOLVES with a structured result — never throws — so the
25
+ * game can branch on `{ ok:false, reason }` (e.g. skip today's summary on
26
+ * `INSUFFICIENT_CREDITS`). The player is billed.
27
+ */
28
+ complete(params: AiCompleteParams): Promise<AiCompleteResult>;
29
+ /** Higher-level helper: a persona-bound NPC that keeps its own conversation. */
30
+ npc(config: NpcConfig): Npc;
31
+ }
32
+ export interface NpcConfig {
33
+ /**
34
+ * Name of a playbook shipped at `public/playbooks/<name>.md` (ADR-018) — the
35
+ * NPC's persona + strategy as editable natural language, kept OUT of code.
36
+ * Preferred over the inline `role`/`goals`/`style` for any non-trivial
37
+ * character (you can tune behavior by editing the `.md`, no code change).
38
+ */
39
+ playbook?: string;
40
+ role?: string;
41
+ goals?: string[];
42
+ style?: string;
43
+ rules?: string[];
44
+ actions?: AiActionDef[];
45
+ }
46
+ /**
47
+ * A single NPC / opponent conversation. Maintains its own history so the game
48
+ * just calls `say(...)`; the persona + declared actions ride along every turn.
49
+ */
50
+ export declare class Npc {
51
+ private ai;
52
+ private config;
53
+ private history;
54
+ constructor(ai: AiModule, config: NpcConfig);
55
+ say(playerLine: string, opts?: {
56
+ observation?: unknown;
57
+ }): Promise<AiActResult>;
58
+ /** Record a world event the NPC should be aware of next turn (no LLM call). */
59
+ note(text: string): void;
60
+ /** Forget the conversation (e.g. the player walked away and came back fresh). */
61
+ reset(): void;
62
+ }
@@ -0,0 +1,107 @@
1
+ import { RpcError } from '../core/Transport.js';
2
+ // LLM latency routinely exceeds the default 10s RPC timeout — give the host
3
+ // (which does the Anthropic round-trip) headroom.
4
+ const AI_RPC_TIMEOUT_MS = 60000;
5
+ /**
6
+ * Runtime AI (ADR-017) — let the game call an in-game LLM at play time
7
+ * ("living NPCs" / AI opponents). The AI is a *virtual player*: it can only
8
+ * TALK (`say`) and CHOOSE an action (`do`) from the vocabulary the game
9
+ * declares — it never mutates game state, which stays the game's job.
10
+ *
11
+ * `act()` always RESOLVES with a structured {@link AiActResult} — never throws —
12
+ * so the game can branch in-fiction on `{ ok:false, reason }` (e.g. prompt an
13
+ * anonymous player to sign in on `SIGN_IN_REQUIRED`). The player is billed.
14
+ */
15
+ export class AiModule {
16
+ constructor(transport) {
17
+ this.transport = transport;
18
+ }
19
+ async act(params) {
20
+ try {
21
+ // The host returns the structured outcome INSIDE the RPC result, so a
22
+ // host-level `{ ok:false, reason }` comes back here as-is. The catch below
23
+ // only fires for transport failures (timeout, no host, unknown method).
24
+ return await this.transport.call('ai.act', params, AI_RPC_TIMEOUT_MS);
25
+ }
26
+ catch (err) {
27
+ return { ok: false, reason: toReason(err) };
28
+ }
29
+ }
30
+ /**
31
+ * Generic ONE-SHOT completion — a raw prompt→text LLM call (ADR-028), the utility
32
+ * sibling of {@link act}. The game supplies the whole prompt and decides what the text
33
+ * is for (summarize / classify / name-generate / score / …); the platform stays
34
+ * task-neutral (auth + credits + safety rails + passthrough). No persona / actions /
35
+ * history — for in-character dialogue use `npc().say()` instead.
36
+ *
37
+ * Like {@link act}, it always RESOLVES with a structured result — never throws — so the
38
+ * game can branch on `{ ok:false, reason }` (e.g. skip today's summary on
39
+ * `INSUFFICIENT_CREDITS`). The player is billed.
40
+ */
41
+ async complete(params) {
42
+ try {
43
+ return await this.transport.call('ai.complete', params, AI_RPC_TIMEOUT_MS);
44
+ }
45
+ catch (err) {
46
+ return { ok: false, reason: toReason(err) };
47
+ }
48
+ }
49
+ /** Higher-level helper: a persona-bound NPC that keeps its own conversation. */
50
+ npc(config) {
51
+ return new Npc(this, config);
52
+ }
53
+ }
54
+ /**
55
+ * A single NPC / opponent conversation. Maintains its own history so the game
56
+ * just calls `say(...)`; the persona + declared actions ride along every turn.
57
+ */
58
+ export class Npc {
59
+ constructor(ai, config) {
60
+ this.ai = ai;
61
+ this.config = config;
62
+ this.history = [];
63
+ }
64
+ async say(playerLine, opts) {
65
+ this.history.push({ from: 'player', text: playerLine });
66
+ const res = await this.ai.act({
67
+ playbook: this.config.playbook,
68
+ persona: {
69
+ role: this.config.role,
70
+ goals: this.config.goals,
71
+ style: this.config.style,
72
+ rules: this.config.rules,
73
+ },
74
+ actions: this.config.actions,
75
+ observation: opts?.observation,
76
+ history: this.history,
77
+ });
78
+ if (res.ok && res.say)
79
+ this.history.push({ from: 'npc', text: res.say });
80
+ return res;
81
+ }
82
+ /** Record a world event the NPC should be aware of next turn (no LLM call). */
83
+ note(text) {
84
+ this.history.push({ from: 'event', text });
85
+ }
86
+ /** Forget the conversation (e.g. the player walked away and came back fresh). */
87
+ reset() {
88
+ this.history = [];
89
+ }
90
+ }
91
+ /** Map a transport-level error code to a structured reason. Host-level failures
92
+ * already arrive as `{ ok:false, reason }` and never reach this. */
93
+ function toReason(err) {
94
+ const code = err instanceof RpcError ? err.code : '';
95
+ switch (code) {
96
+ case 'SIGN_IN_REQUIRED':
97
+ case 'UNAUTHENTICATED':
98
+ return 'SIGN_IN_REQUIRED';
99
+ case 'INSUFFICIENT_CREDITS':
100
+ case 'PAYMENT_REQUIRED':
101
+ return 'INSUFFICIENT_CREDITS';
102
+ case 'RATE_LIMITED':
103
+ return 'RATE_LIMITED';
104
+ default:
105
+ return 'UNAVAILABLE';
106
+ }
107
+ }
@@ -0,0 +1,42 @@
1
+ import type { UmicatUser } from '../protocol.js';
2
+ /**
3
+ * Low-level transport for platform RPC calls. Swapped per host.
4
+ *
5
+ * Current implementations:
6
+ * - PostMessageTransport: iframe hosted inside umicat-home-ui
7
+ * - LocalStorageTransport: game running standalone (no host, no user)
8
+ *
9
+ * Future implementations (not shipped):
10
+ * - DiscordTransport: game running as a Discord Activity
11
+ */
12
+ export interface Transport {
13
+ readonly kind: TransportKind;
14
+ readonly user: UmicatUser | null;
15
+ readonly gameId: string;
16
+ /**
17
+ * Endpoint for umicat-realtime-service (WebSocket URL). Set by the host
18
+ * during handshake when multiplayer is enabled. Absent in standalone mode —
19
+ * `umicat.rooms.*` is unavailable in that case.
20
+ */
21
+ readonly realtimeUrl?: string;
22
+ /** The player's preferred language (BCP-47-ish, e.g. 'en', 'zh-CN'). The host
23
+ * provides it at handshake; standalone falls back to the browser locale. */
24
+ readonly locale?: string;
25
+ /** Capabilities the host advertised at handshake (e.g. 'voice' when the native
26
+ * app can run platform speech-to-text). Empty/undefined when standalone or on
27
+ * a host that doesn't declare them. Lets modules feature-detect host-backed
28
+ * primitives (see VoiceModule). */
29
+ readonly capabilities?: readonly string[];
30
+ /** Subscribe to a host-pushed event stream (e.g. 'umicat:voice' streaming
31
+ * transcript + mic level). Returns an unsubscribe fn. Only hosts that push
32
+ * events implement this; absent on standalone — callers must feature-detect. */
33
+ on?(event: string, handler: (payload: unknown) => void): () => void;
34
+ /** `timeoutMs` overrides the default RPC timeout — runtime-AI calls need
35
+ * longer than the 10s default for LLM latency. */
36
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
37
+ }
38
+ export type TransportKind = 'umicat-home-ui' | 'standalone' | 'discord';
39
+ export declare class RpcError extends Error {
40
+ code: string;
41
+ constructor(code: string, message: string);
42
+ }
@@ -0,0 +1,7 @@
1
+ export class RpcError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ this.name = 'RpcError';
6
+ }
7
+ }
@@ -0,0 +1,80 @@
1
+ import type { Transport, TransportKind } from './Transport.js';
2
+ import { SavesModule } from '../saves/SavesModule.js';
3
+ import { GameDataModule } from '../gamedata/GameDataModule.js';
4
+ import { RealtimeModule } from '../realtime/RealtimeModule.js';
5
+ import { AiModule } from '../ai/AiModule.js';
6
+ import { PlatformModule } from '../platform/PlatformModule.js';
7
+ import { VoiceModule } from '../voice/VoiceModule.js';
8
+ import type { UmicatUser } from '../protocol.js';
9
+ export interface UmicatInitOptions {
10
+ /** Handshake timeout in ms when running inside a host. Default 5000. */
11
+ handshakeTimeoutMs?: number;
12
+ /**
13
+ * Game ID used for the localStorage fallback. Ignored when connected to a
14
+ * host (the host provides the authoritative gameId). Default 'standalone'.
15
+ */
16
+ standaloneGameId?: string;
17
+ }
18
+ /**
19
+ * Umicat platform services bound to the current (game, user) — with no
20
+ * rendering engine anywhere in sight.
21
+ *
22
+ * The public API is host-agnostic: `connect()` detects the host (home-ui
23
+ * iframe, Discord Activity, or standalone) and picks the right transport.
24
+ * Games should not care which one is active.
25
+ *
26
+ * **Why this class exists separately from `@umicat/phaser-sdk`'s `Umicat`.**
27
+ * Identity, cloud saves, shared game data, multiplayer, runtime AI and voice
28
+ * are the same for every runtime we will ever ship. They were already written
29
+ * without touching Phaser; the only thing tying the composition root to a 2D
30
+ * engine was that it constructed a Phaser dialogue box. That is now the
31
+ * subclass's job, so a 3D runtime — or an engine we have not picked yet —
32
+ * inherits the platform whole instead of reimplementing it.
33
+ *
34
+ * Runtimes extend this and add their own engine-shaped modules:
35
+ * `@umicat/phaser-sdk`'s `Umicat` adds `dialogue` wired to `DialogueBox`.
36
+ */
37
+ export declare class UmicatCore {
38
+ protected readonly transport: Transport;
39
+ readonly saves: SavesModule;
40
+ readonly gameData: GameDataModule;
41
+ readonly rooms: RealtimeModule;
42
+ /** Runtime AI — in-game LLM / living NPCs (ADR-017). */
43
+ readonly ai: AiModule;
44
+ /** Actions that belong to the surface, not the game — today: leaving it. */
45
+ readonly platform: PlatformModule;
46
+ /** Voice input (speech-to-text) with a live mic level for waveform UIs.
47
+ * Browser recognition in a browser, native platform recognizer in the app. */
48
+ readonly voice: VoiceModule;
49
+ protected constructor(transport: Transport);
50
+ /** The current authenticated user, or `null` if anonymous / standalone. */
51
+ get user(): UmicatUser | null;
52
+ get isAuthenticated(): boolean;
53
+ /** Stable game identifier issued by the host. May be a placeholder when standalone. */
54
+ get gameId(): string;
55
+ /** The player's preferred language (e.g. 'en', 'zh-CN'). Use it to default the
56
+ * game's UI + AI NPC language. See the game-i18n skill. */
57
+ get locale(): string;
58
+ /** Which transport the handshake selected. Useful for debugging. */
59
+ get host(): TransportKind;
60
+ /**
61
+ * Negotiate a transport. Runtimes call this from their own `init()` and pass
62
+ * the result to their constructor.
63
+ *
64
+ * Resolution order:
65
+ * 1. If embedded in a window that responds to the Umicat handshake → PostMessageTransport
66
+ * 2. Otherwise → LocalStorageTransport (anonymous, local-only saves)
67
+ *
68
+ * `sdkVersion` is reported to the host in the handshake. Each runtime passes
69
+ * its OWN published version — the core does not have a meaningful one to
70
+ * report, and the host uses it to reason about the game's capabilities.
71
+ */
72
+ static connect(sdkVersion: string, options?: UmicatInitOptions): Promise<Transport>;
73
+ /**
74
+ * Standalone entry point for runtimes that add no modules of their own.
75
+ * Named `start`, not `init`, deliberately: a subclass wants `init()` for its
76
+ * own richer return type, and TypeScript forbids a static override whose
77
+ * signature is incompatible with the base.
78
+ */
79
+ static start(sdkVersion: string, options?: UmicatInitOptions): Promise<UmicatCore>;
80
+ }
@@ -0,0 +1,89 @@
1
+ import { PostMessageTransport } from './transports/PostMessageTransport.js';
2
+ import { LocalStorageTransport } from './transports/LocalStorageTransport.js';
3
+ import { SavesModule } from '../saves/SavesModule.js';
4
+ import { GameDataModule } from '../gamedata/GameDataModule.js';
5
+ import { RealtimeModule } from '../realtime/RealtimeModule.js';
6
+ import { AiModule } from '../ai/AiModule.js';
7
+ import { PlatformModule } from '../platform/PlatformModule.js';
8
+ import { VoiceModule } from '../voice/VoiceModule.js';
9
+ /**
10
+ * Umicat platform services bound to the current (game, user) — with no
11
+ * rendering engine anywhere in sight.
12
+ *
13
+ * The public API is host-agnostic: `connect()` detects the host (home-ui
14
+ * iframe, Discord Activity, or standalone) and picks the right transport.
15
+ * Games should not care which one is active.
16
+ *
17
+ * **Why this class exists separately from `@umicat/phaser-sdk`'s `Umicat`.**
18
+ * Identity, cloud saves, shared game data, multiplayer, runtime AI and voice
19
+ * are the same for every runtime we will ever ship. They were already written
20
+ * without touching Phaser; the only thing tying the composition root to a 2D
21
+ * engine was that it constructed a Phaser dialogue box. That is now the
22
+ * subclass's job, so a 3D runtime — or an engine we have not picked yet —
23
+ * inherits the platform whole instead of reimplementing it.
24
+ *
25
+ * Runtimes extend this and add their own engine-shaped modules:
26
+ * `@umicat/phaser-sdk`'s `Umicat` adds `dialogue` wired to `DialogueBox`.
27
+ */
28
+ export class UmicatCore {
29
+ constructor(transport) {
30
+ this.transport = transport;
31
+ // Saves are per-user. When the viewer is anonymous, route save calls to
32
+ // localStorage even if a host is attached — the backend requires auth,
33
+ // so forwarding would 401. Games see the same `umicat.saves` API either
34
+ // way and don't have to branch on auth state.
35
+ const savesTransport = transport.user === null
36
+ ? new LocalStorageTransport(transport.gameId || 'anonymous')
37
+ : transport;
38
+ this.saves = new SavesModule(savesTransport);
39
+ // gameData is game-scope: reads are public (anonymous OK), only writes
40
+ // need auth. The primary transport handles both; if it's a LocalStorage
41
+ // fallback (standalone mode) that also works — the module speaks the
42
+ // same RPC interface.
43
+ this.gameData = new GameDataModule(transport);
44
+ // Multiplayer requires a realtime endpoint from the host. When absent
45
+ // (standalone or a host without multiplayer), methods throw a clear
46
+ // REALTIME_UNAVAILABLE error rather than silently misbehaving.
47
+ this.rooms = new RealtimeModule(transport, transport.realtimeUrl);
48
+ this.ai = new AiModule(transport);
49
+ this.platform = new PlatformModule(transport);
50
+ this.voice = new VoiceModule(transport);
51
+ }
52
+ /** The current authenticated user, or `null` if anonymous / standalone. */
53
+ get user() { return this.transport.user; }
54
+ get isAuthenticated() { return this.transport.user !== null; }
55
+ /** Stable game identifier issued by the host. May be a placeholder when standalone. */
56
+ get gameId() { return this.transport.gameId; }
57
+ /** The player's preferred language (e.g. 'en', 'zh-CN'). Use it to default the
58
+ * game's UI + AI NPC language. See the game-i18n skill. */
59
+ get locale() { return this.transport.locale ?? 'en'; }
60
+ /** Which transport the handshake selected. Useful for debugging. */
61
+ get host() { return this.transport.kind; }
62
+ /**
63
+ * Negotiate a transport. Runtimes call this from their own `init()` and pass
64
+ * the result to their constructor.
65
+ *
66
+ * Resolution order:
67
+ * 1. If embedded in a window that responds to the Umicat handshake → PostMessageTransport
68
+ * 2. Otherwise → LocalStorageTransport (anonymous, local-only saves)
69
+ *
70
+ * `sdkVersion` is reported to the host in the handshake. Each runtime passes
71
+ * its OWN published version — the core does not have a meaningful one to
72
+ * report, and the host uses it to reason about the game's capabilities.
73
+ */
74
+ static async connect(sdkVersion, options = {}) {
75
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5000;
76
+ const standaloneGameId = options.standaloneGameId ?? 'standalone';
77
+ const pm = await PostMessageTransport.connect(handshakeTimeoutMs, sdkVersion);
78
+ return pm ?? new LocalStorageTransport(standaloneGameId);
79
+ }
80
+ /**
81
+ * Standalone entry point for runtimes that add no modules of their own.
82
+ * Named `start`, not `init`, deliberately: a subclass wants `init()` for its
83
+ * own richer return type, and TypeScript forbids a static override whose
84
+ * signature is incompatible with the base.
85
+ */
86
+ static async start(sdkVersion, options = {}) {
87
+ return new UmicatCore(await UmicatCore.connect(sdkVersion, options));
88
+ }
89
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Cache-bust fixed-path content fetches (manifest / scene / HUD / tilemap /
3
+ * atlas / rules / waves / game.json).
4
+ *
5
+ * These files live at STABLE urls (`scenes/world/main.json`, …) but their
6
+ * CONTENTS change every build / republish. The session-server serves them
7
+ * `no-cache` (revalidate), which is correct — BUT if any deploy path ever
8
+ * mistakenly served one `immutable` (a header bug), the browser caches it and
9
+ * NEVER revalidates, even on hard refresh. The stale copy then sticks: the game
10
+ * reloads after a Build / republish and the SDK re-fetches the scene, but the
11
+ * browser hands back the OLD bytes → "my edits reverted after Build", and a
12
+ * republished game shows players the previous version. This bit Catopia twice
13
+ * (world `main.json`, then `game-hud.json`).
14
+ *
15
+ * Fix: append a per-BUILD token to each such fetch. The token is the game's
16
+ * hashed bundle filename (`index-DAxxxx`), which Vite regenerates every build —
17
+ * so each build gets a FRESH url the browser has never cached (defeating any
18
+ * stale `immutable` entry), while the url stays IDENTICAL within a build (so the
19
+ * CDN still caches it — no per-request S3 hit). Content-hashed `assets/**` are
20
+ * untouched (already correctly `immutable`).
21
+ */
22
+ /** The per-build cache token (memoized once per page load). */
23
+ export declare function buildCacheToken(): string;
24
+ /** Append the per-build cache-bust query param to a fetch path. */
25
+ export declare function bustCache(path: string): string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Cache-bust fixed-path content fetches (manifest / scene / HUD / tilemap /
3
+ * atlas / rules / waves / game.json).
4
+ *
5
+ * These files live at STABLE urls (`scenes/world/main.json`, …) but their
6
+ * CONTENTS change every build / republish. The session-server serves them
7
+ * `no-cache` (revalidate), which is correct — BUT if any deploy path ever
8
+ * mistakenly served one `immutable` (a header bug), the browser caches it and
9
+ * NEVER revalidates, even on hard refresh. The stale copy then sticks: the game
10
+ * reloads after a Build / republish and the SDK re-fetches the scene, but the
11
+ * browser hands back the OLD bytes → "my edits reverted after Build", and a
12
+ * republished game shows players the previous version. This bit Catopia twice
13
+ * (world `main.json`, then `game-hud.json`).
14
+ *
15
+ * Fix: append a per-BUILD token to each such fetch. The token is the game's
16
+ * hashed bundle filename (`index-DAxxxx`), which Vite regenerates every build —
17
+ * so each build gets a FRESH url the browser has never cached (defeating any
18
+ * stale `immutable` entry), while the url stays IDENTICAL within a build (so the
19
+ * CDN still caches it — no per-request S3 hit). Content-hashed `assets/**` are
20
+ * untouched (already correctly `immutable`).
21
+ */
22
+ let cached = null;
23
+ function computeToken() {
24
+ try {
25
+ if (typeof document !== 'undefined') {
26
+ // Vite emits one `<script type="module" src="/assets/index-<hash>.js">`.
27
+ // The hash changes per build → perfect per-build cache key.
28
+ const s = document.querySelector('script[type="module"][src]');
29
+ const src = s?.src;
30
+ if (src) {
31
+ const file = src.split('/').pop();
32
+ if (file)
33
+ return file.replace(/\.[a-z0-9]+$/i, ''); // "index-DAxxxx"
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ /* fall through to the random fallback */
39
+ }
40
+ // No module script found (unusual host / test harness): a per-LOAD token —
41
+ // still never serves stale, at the cost of a re-fetch each load.
42
+ try {
43
+ return 'r' + Math.random().toString(36).slice(2, 10);
44
+ }
45
+ catch {
46
+ return 'v';
47
+ }
48
+ }
49
+ /** The per-build cache token (memoized once per page load). */
50
+ export function buildCacheToken() {
51
+ if (cached === null)
52
+ cached = computeToken();
53
+ return cached;
54
+ }
55
+ /** Append the per-build cache-bust query param to a fetch path. */
56
+ export function bustCache(path) {
57
+ const sep = path.includes('?') ? '&' : '?';
58
+ return `${path}${sep}v=${encodeURIComponent(buildCacheToken())}`;
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { Transport } from '../Transport.js';
2
+ import type { UmicatUser } from '../../protocol.js';
3
+ /**
4
+ * Fallback transport when the game runs without a host (e.g., opened directly
5
+ * outside umicat-home-ui) or when the viewer is anonymous. Data lives in
6
+ * localStorage, keyed by gameId. No network calls. Quotas mirror the backend.
7
+ */
8
+ export declare class LocalStorageTransport implements Transport {
9
+ readonly gameId: string;
10
+ readonly kind: "standalone";
11
+ readonly user: UmicatUser | null;
12
+ readonly locale: string;
13
+ readonly capabilities: readonly string[];
14
+ constructor(gameId: string);
15
+ call<T = unknown>(method: string, params?: unknown): Promise<T>;
16
+ private savesPrefix;
17
+ private gameDataPrefix;
18
+ private read;
19
+ private write;
20
+ private kvGet;
21
+ private kvSet;
22
+ private kvDelete;
23
+ private kvList;
24
+ }
@@ -0,0 +1,83 @@
1
+ import { RpcError } from '../Transport.js';
2
+ /**
3
+ * Fallback transport when the game runs without a host (e.g., opened directly
4
+ * outside umicat-home-ui) or when the viewer is anonymous. Data lives in
5
+ * localStorage, keyed by gameId. No network calls. Quotas mirror the backend.
6
+ */
7
+ export class LocalStorageTransport {
8
+ constructor(gameId) {
9
+ this.gameId = gameId;
10
+ this.kind = 'standalone';
11
+ this.user = null;
12
+ // No host to tell us the player's language — best-effort from the browser.
13
+ this.locale = (typeof navigator !== 'undefined' && navigator.language) ? navigator.language : 'en';
14
+ // Standalone advertises no host capabilities — voice etc. fall back to the
15
+ // browser's own APIs (or are unsupported).
16
+ this.capabilities = [];
17
+ }
18
+ async call(method, params) {
19
+ switch (method) {
20
+ case 'saves.get': return this.kvGet(this.savesPrefix(), params);
21
+ case 'saves.set': return this.kvSet(this.savesPrefix(), params);
22
+ case 'saves.delete': return this.kvDelete(this.savesPrefix(), params);
23
+ case 'saves.list': return this.kvList(this.savesPrefix());
24
+ case 'gameData.get': return this.kvGet(this.gameDataPrefix(), params);
25
+ case 'gameData.set': return this.kvSet(this.gameDataPrefix(), params);
26
+ case 'gameData.delete': return this.kvDelete(this.gameDataPrefix(), params);
27
+ case 'gameData.list': return this.kvList(this.gameDataPrefix());
28
+ default: throw new RpcError('UNKNOWN_METHOD', `Unknown method: ${method}`);
29
+ }
30
+ }
31
+ savesPrefix() { return `umicat:saves:${this.gameId}:`; }
32
+ gameDataPrefix() { return `umicat:gameData:${this.gameId}:`; }
33
+ read(prefix, key) {
34
+ if (typeof localStorage === 'undefined')
35
+ return null;
36
+ const raw = localStorage.getItem(prefix + key);
37
+ if (!raw)
38
+ return null;
39
+ try {
40
+ return JSON.parse(raw);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ write(prefix, key, rec) {
47
+ if (typeof localStorage === 'undefined')
48
+ throw new RpcError('UNAVAILABLE', 'localStorage unavailable');
49
+ localStorage.setItem(prefix + key, JSON.stringify(rec));
50
+ }
51
+ kvGet(prefix, { key }) {
52
+ const rec = this.read(prefix, key);
53
+ return rec ? { value: rec.value, version: rec.version } : { value: null, version: null };
54
+ }
55
+ kvSet(prefix, { key, value, ifVersion }) {
56
+ const existing = this.read(prefix, key);
57
+ if (ifVersion !== undefined && existing && existing.version !== ifVersion) {
58
+ throw new RpcError('VERSION_MISMATCH', `Expected version ${ifVersion} but stored is ${existing.version}`);
59
+ }
60
+ const next = { value, version: (existing?.version ?? 0) + 1 };
61
+ this.write(prefix, key, next);
62
+ return { version: next.version };
63
+ }
64
+ kvDelete(prefix, { key }) {
65
+ if (typeof localStorage === 'undefined')
66
+ return { deleted: false };
67
+ const full = prefix + key;
68
+ const existed = localStorage.getItem(full) !== null;
69
+ localStorage.removeItem(full);
70
+ return { deleted: existed };
71
+ }
72
+ kvList(prefix) {
73
+ if (typeof localStorage === 'undefined')
74
+ return { keys: [] };
75
+ const keys = [];
76
+ for (let i = 0; i < localStorage.length; i++) {
77
+ const k = localStorage.key(i);
78
+ if (k && k.startsWith(prefix))
79
+ keys.push(k.slice(prefix.length));
80
+ }
81
+ return { keys };
82
+ }
83
+ }