@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 +82 -0
- package/dist/ai/AiModule.d.ts +62 -0
- package/dist/ai/AiModule.js +107 -0
- package/dist/core/Transport.d.ts +42 -0
- package/dist/core/Transport.js +7 -0
- package/dist/core/UmicatCore.d.ts +80 -0
- package/dist/core/UmicatCore.js +89 -0
- package/dist/core/cacheBust.d.ts +25 -0
- package/dist/core/cacheBust.js +59 -0
- package/dist/core/transports/LocalStorageTransport.d.ts +24 -0
- package/dist/core/transports/LocalStorageTransport.js +83 -0
- package/dist/core/transports/PostMessageTransport.d.ts +32 -0
- package/dist/core/transports/PostMessageTransport.js +127 -0
- package/dist/dialogue/DialogueModule.d.ts +96 -0
- package/dist/dialogue/DialogueModule.js +156 -0
- package/dist/dialogue/runner.d.ts +108 -0
- package/dist/dialogue/runner.js +80 -0
- package/dist/gamedata/GameDataModule.d.ts +45 -0
- package/dist/gamedata/GameDataModule.js +59 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +20 -0
- package/dist/platform/PlatformModule.d.ts +47 -0
- package/dist/platform/PlatformModule.js +63 -0
- package/dist/protocol.d.ts +238 -0
- package/dist/protocol.js +11 -0
- package/dist/realtime/RealtimeModule.d.ts +93 -0
- package/dist/realtime/RealtimeModule.js +115 -0
- package/dist/realtime/UmicatRoom.d.ts +197 -0
- package/dist/realtime/UmicatRoom.js +353 -0
- package/dist/saves/SavesModule.d.ts +23 -0
- package/dist/saves/SavesModule.js +37 -0
- package/dist/voice/VoiceModule.d.ts +44 -0
- package/dist/voice/VoiceModule.js +234 -0
- package/package.json +48 -0
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,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
|
+
}
|