@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.
@@ -0,0 +1,32 @@
1
+ import type { Transport } from '../Transport.js';
2
+ import { type UmicatUser } from '../../protocol.js';
3
+ /**
4
+ * Connects to a parent window that implements the Umicat RPC host protocol.
5
+ * Used when the game is loaded inside umicat-home-ui.
6
+ */
7
+ export declare class PostMessageTransport implements Transport {
8
+ private parent;
9
+ readonly kind: "umicat-home-ui";
10
+ user: UmicatUser | null;
11
+ gameId: string;
12
+ realtimeUrl?: string;
13
+ locale?: string;
14
+ capabilities: readonly string[];
15
+ private pending;
16
+ private seq;
17
+ private eventHandlers;
18
+ private constructor();
19
+ /**
20
+ * Perform the handshake. Resolves once parent has replied with `umicat:init`.
21
+ * Resolves to null if no response within `timeoutMs`.
22
+ *
23
+ * The hello is retried at `helloRetryMs` intervals because the parent's
24
+ * RPC-host message listener may not be mounted yet when the iframe first
25
+ * fires hello — especially on slower mobile devices where React takes
26
+ * longer to boot. One-shot hello was losing the handshake on Android.
27
+ */
28
+ static connect(timeoutMs?: number, sdkVersion?: string, helloRetryMs?: number): Promise<PostMessageTransport | null>;
29
+ private installResultListener;
30
+ on(event: string, handler: (payload: unknown) => void): () => void;
31
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
32
+ }
@@ -0,0 +1,127 @@
1
+ import { RpcError } from '../Transport.js';
2
+ import { PROTOCOL_VERSION, } from '../../protocol.js';
3
+ /**
4
+ * Connects to a parent window that implements the Umicat RPC host protocol.
5
+ * Used when the game is loaded inside umicat-home-ui.
6
+ */
7
+ export class PostMessageTransport {
8
+ constructor(parent) {
9
+ this.parent = parent;
10
+ this.kind = 'umicat-home-ui';
11
+ this.user = null;
12
+ this.gameId = '';
13
+ this.capabilities = [];
14
+ this.pending = new Map();
15
+ this.seq = 0;
16
+ this.eventHandlers = new Map();
17
+ }
18
+ /**
19
+ * Perform the handshake. Resolves once parent has replied with `umicat:init`.
20
+ * Resolves to null if no response within `timeoutMs`.
21
+ *
22
+ * The hello is retried at `helloRetryMs` intervals because the parent's
23
+ * RPC-host message listener may not be mounted yet when the iframe first
24
+ * fires hello — especially on slower mobile devices where React takes
25
+ * longer to boot. One-shot hello was losing the handshake on Android.
26
+ */
27
+ static async connect(timeoutMs = 5000, sdkVersion = '0.0.0', helloRetryMs = 200) {
28
+ if (typeof window === 'undefined' || window.parent === window)
29
+ return null;
30
+ const transport = new PostMessageTransport(window.parent);
31
+ const hello = {
32
+ type: 'umicat:hello',
33
+ protocolVersion: PROTOCOL_VERSION,
34
+ sdkVersion,
35
+ };
36
+ const init = await new Promise((resolve) => {
37
+ let settled = false;
38
+ const finish = (value) => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ window.removeEventListener('message', onMessage);
43
+ clearInterval(helloInterval);
44
+ clearTimeout(timeoutHandle);
45
+ resolve(value);
46
+ };
47
+ const onMessage = (event) => {
48
+ if (event.source !== window.parent)
49
+ return;
50
+ const data = event.data;
51
+ if (!data || data.type !== 'umicat:init')
52
+ return;
53
+ finish(data);
54
+ };
55
+ window.addEventListener('message', onMessage);
56
+ // Fire the first hello immediately, then keep retrying until init
57
+ // arrives or timeout fires. Retries are cheap (a postMessage with a
58
+ // small payload); the parent's RPC host replies on first valid hello.
59
+ transport.parent.postMessage(hello, '*');
60
+ const helloInterval = setInterval(() => {
61
+ transport.parent.postMessage(hello, '*');
62
+ }, helloRetryMs);
63
+ const timeoutHandle = setTimeout(() => finish(null), timeoutMs);
64
+ });
65
+ if (!init)
66
+ return null;
67
+ if (init.protocolVersion !== PROTOCOL_VERSION) {
68
+ console.warn('[UmicatSDK] protocol version mismatch', init.protocolVersion, PROTOCOL_VERSION);
69
+ }
70
+ transport.user = init.user;
71
+ transport.gameId = init.gameId;
72
+ transport.realtimeUrl = init.realtimeUrl;
73
+ transport.locale = init.locale;
74
+ transport.capabilities = init.capabilities ?? [];
75
+ transport.installResultListener();
76
+ return transport;
77
+ }
78
+ installResultListener() {
79
+ window.addEventListener('message', (event) => {
80
+ if (event.source !== this.parent)
81
+ return;
82
+ const data = event.data;
83
+ if (!data || typeof data.type !== 'string')
84
+ return;
85
+ if (data.type === 'umicat:rpc.result') {
86
+ const result = data;
87
+ const pending = this.pending.get(result.id);
88
+ if (!pending)
89
+ return;
90
+ this.pending.delete(result.id);
91
+ if (result.ok)
92
+ pending.resolve(result.result);
93
+ else
94
+ pending.reject(new RpcError(result.error.code, result.error.message));
95
+ return;
96
+ }
97
+ // Host-pushed event stream (e.g. 'umicat:voice') → registered handlers.
98
+ const handlers = this.eventHandlers.get(data.type);
99
+ if (handlers)
100
+ for (const handler of handlers)
101
+ handler(data);
102
+ });
103
+ }
104
+ on(event, handler) {
105
+ let handlers = this.eventHandlers.get(event);
106
+ if (!handlers) {
107
+ handlers = new Set();
108
+ this.eventHandlers.set(event, handlers);
109
+ }
110
+ handlers.add(handler);
111
+ return () => { handlers.delete(handler); };
112
+ }
113
+ call(method, params, timeoutMs = 10000) {
114
+ const id = `${++this.seq}-${Date.now()}`;
115
+ const message = { type: 'umicat:rpc', id, method, params };
116
+ return new Promise((resolve, reject) => {
117
+ this.pending.set(id, { resolve: resolve, reject });
118
+ this.parent.postMessage(message, '*');
119
+ setTimeout(() => {
120
+ if (this.pending.has(id)) {
121
+ this.pending.delete(id);
122
+ reject(new RpcError('TIMEOUT', `RPC ${method} timed out`));
123
+ }
124
+ }, timeoutMs);
125
+ });
126
+ }
127
+ }
@@ -0,0 +1,96 @@
1
+ import type { SavesModule } from '../saves/SavesModule.js';
2
+ /**
3
+ * What a dialogue UI has to do. Engine-neutral on purpose: the Phaser SDK's
4
+ * `DialogueBox` implements it, a 3D runtime can implement it with DOM or with
5
+ * meshes, and a game can supply its own.
6
+ */
7
+ export interface DialogueRenderer {
8
+ /** Show a spoken line; call `onAdvance` when the player taps/keys to continue.
9
+ * `data` = the node's game-interpreted key/values (see DialogueLineNode.data). */
10
+ showLine(text: string, opts: {
11
+ speaker?: string;
12
+ emote?: string;
13
+ spotlight?: string | null;
14
+ data?: Record<string, string>;
15
+ }, onAdvance: () => void): void;
16
+ /** Show choices; call `onPick(i)` with the chosen index. */
17
+ showChoices(prompt: string | null, options: string[], onPick: (i: number) => void, opts?: {
18
+ speaker?: string;
19
+ data?: Record<string, string>;
20
+ }): void;
21
+ /** Tear down the UI. */
22
+ close(): void;
23
+ }
24
+ export interface DialoguePlayOptions<TTheme = unknown> {
25
+ /** Override `{name}` (defaults to the signed-in user's name, else 'friend'). */
26
+ playerName?: string;
27
+ /** Extra `{placeholder}` → value substitutions applied to every line. */
28
+ vars?: Record<string, string>;
29
+ /** Play even if the script has already been seen (default: replay allowed;
30
+ * set to false to skip when seen — the once-only intro pattern). */
31
+ replay?: boolean;
32
+ /** Use a custom themed UI instead of the runtime's built-in box. */
33
+ renderer?: DialogueRenderer;
34
+ /** Theme the built-in box (ignored when `renderer` is supplied). */
35
+ theme?: TTheme;
36
+ /** Highlight a named UI target while a line with `spotlight` shows
37
+ * (the game resolves the name → an on-screen rect). Cleared on end.
38
+ * Convenience wrapper over `onData` for the common tutorial case. */
39
+ onSpotlight?: (target: string | null) => void;
40
+ /** The generic hook: called with each node's game-interpreted key/values
41
+ * (`data`) as it shows — `{ spotlight, sound, camera, … }`, whatever your
42
+ * scripts author in the Dialogue tool's "Custom data". `{}` when none. */
43
+ onData?: (data: Record<string, string>) => void;
44
+ }
45
+ /**
46
+ * Scripted dialogue (authored, non-AI conversations): intros, cutscenes,
47
+ * tutorials, branching talks. A script is a graph of nodes authored in the
48
+ * Dialogue tool and shipped at `public/dialogue/<id>.json`; this module loads
49
+ * one, walks it with a {@link DialogueRunner}, and renders it through whatever
50
+ * {@link DialogueRenderer} the runtime supplies.
51
+ *
52
+ * **Engine-neutral by construction.** The only thing that ever knew about
53
+ * Phaser here was the default renderer, which is now injected: the Phaser SDK
54
+ * passes `(scene, theme) => new DialogueBox(scene, theme)` and a 3D runtime
55
+ * passes its own. `TScene` is whatever that renderer needs handed to it.
56
+ *
57
+ * Distinct from `umicat.ai` (runtime LLM chat): dialogue here is deterministic,
58
+ * hand-written and localized. Flags + seen-state persist per-user via
59
+ * `umicat.saves`, so a once-only intro stays played and branch flags survive
60
+ * across sessions.
61
+ */
62
+ export declare class DialogueModule<TScene = unknown, TTheme = unknown> {
63
+ private readonly saves;
64
+ private readonly getLocale;
65
+ private readonly getUserName;
66
+ /** Builds the runtime's built-in dialogue UI when the caller supplies none.
67
+ * Omit it and `play()` requires an explicit `opts.renderer`. */
68
+ private readonly makeDefaultRenderer?;
69
+ private state;
70
+ private loading?;
71
+ private scripts;
72
+ private active?;
73
+ constructor(saves: SavesModule, getLocale: () => string, getUserName: () => string | null,
74
+ /** Builds the runtime's built-in dialogue UI when the caller supplies none.
75
+ * Omit it and `play()` requires an explicit `opts.renderer`. */
76
+ makeDefaultRenderer?: ((scene: TScene, theme?: TTheme) => DialogueRenderer) | undefined);
77
+ /**
78
+ * Play a dialogue script by id. Resolves when it ends (or immediately with
79
+ * `false` if `replay:false` and it was already seen). Only one dialogue runs
80
+ * at a time — starting a new one ends the current.
81
+ */
82
+ play(scene: TScene, scriptId: string, opts?: DialoguePlayOptions<TTheme>): Promise<boolean>;
83
+ /** Has the player already finished this script? (once-only intro checks). */
84
+ hasSeen(scriptId: string): Promise<boolean>;
85
+ /** Mark a script seen WITHOUT playing it (e.g. skip an intro for a returning save). */
86
+ markSeen(scriptId: string): Promise<void>;
87
+ /** Read a persistent dialogue flag. */
88
+ getFlag(flag: string): Promise<boolean>;
89
+ /** Set a persistent dialogue flag. */
90
+ setFlag(flag: string, value?: boolean): Promise<void>;
91
+ /** Forget all seen-state + flags (a fresh-start / debug reset). */
92
+ reset(): Promise<void>;
93
+ private ensureState;
94
+ private persist;
95
+ private loadScript;
96
+ }
@@ -0,0 +1,156 @@
1
+ import { bustCache } from '../core/cacheBust.js';
2
+ import { DialogueRunner, resolveLangText } from './runner.js';
3
+ /** Folder (under the web root, i.e. `public/`) where authored scripts live. */
4
+ const DIALOGUE_DIR = 'dialogue';
5
+ /** One save key holds all dialogue state (seen scripts + flags) for the user. */
6
+ const SAVE_KEY = '__dialogue';
7
+ /**
8
+ * Scripted dialogue (authored, non-AI conversations): intros, cutscenes,
9
+ * tutorials, branching talks. A script is a graph of nodes authored in the
10
+ * Dialogue tool and shipped at `public/dialogue/<id>.json`; this module loads
11
+ * one, walks it with a {@link DialogueRunner}, and renders it through whatever
12
+ * {@link DialogueRenderer} the runtime supplies.
13
+ *
14
+ * **Engine-neutral by construction.** The only thing that ever knew about
15
+ * Phaser here was the default renderer, which is now injected: the Phaser SDK
16
+ * passes `(scene, theme) => new DialogueBox(scene, theme)` and a 3D runtime
17
+ * passes its own. `TScene` is whatever that renderer needs handed to it.
18
+ *
19
+ * Distinct from `umicat.ai` (runtime LLM chat): dialogue here is deterministic,
20
+ * hand-written and localized. Flags + seen-state persist per-user via
21
+ * `umicat.saves`, so a once-only intro stays played and branch flags survive
22
+ * across sessions.
23
+ */
24
+ export class DialogueModule {
25
+ constructor(saves, getLocale, getUserName,
26
+ /** Builds the runtime's built-in dialogue UI when the caller supplies none.
27
+ * Omit it and `play()` requires an explicit `opts.renderer`. */
28
+ makeDefaultRenderer) {
29
+ this.saves = saves;
30
+ this.getLocale = getLocale;
31
+ this.getUserName = getUserName;
32
+ this.makeDefaultRenderer = makeDefaultRenderer;
33
+ this.state = null;
34
+ this.scripts = new Map();
35
+ }
36
+ /**
37
+ * Play a dialogue script by id. Resolves when it ends (or immediately with
38
+ * `false` if `replay:false` and it was already seen). Only one dialogue runs
39
+ * at a time — starting a new one ends the current.
40
+ */
41
+ async play(scene, scriptId, opts = {}) {
42
+ const state = await this.ensureState();
43
+ if (opts.replay === false && state.seen.includes(scriptId))
44
+ return false;
45
+ const script = await this.loadScript(scriptId);
46
+ if (!script)
47
+ return false;
48
+ this.active?.stop();
49
+ const renderer = opts.renderer ?? this.makeDefaultRenderer?.(scene, opts.theme);
50
+ if (!renderer) {
51
+ throw new Error('DialogueModule: no renderer. Pass `opts.renderer`, or construct the module ' +
52
+ 'with a default-renderer factory (the Phaser SDK supplies DialogueBox).');
53
+ }
54
+ const subs = {
55
+ name: opts.playerName ?? this.getUserName() ?? 'friend',
56
+ ...opts.vars,
57
+ };
58
+ const tr = (t) => substitute(resolveLangText(t, this.getLocale()), subs);
59
+ return new Promise((resolve) => {
60
+ const host = {
61
+ showLine: (text, o) => {
62
+ opts.onSpotlight?.(o.spotlight ?? null);
63
+ opts.onData?.(o.data ?? {});
64
+ renderer.showLine(text, o, () => this.active?.advance());
65
+ },
66
+ showChoices: (prompt, options, pick, o) => {
67
+ opts.onSpotlight?.(null);
68
+ opts.onData?.(o?.data ?? {});
69
+ renderer.showChoices(prompt, options, pick, o);
70
+ },
71
+ getFlag: (f) => state.flags[f] === true,
72
+ setFlag: (f, v) => { state.flags[f] = v; void this.persist(); },
73
+ finish: () => {
74
+ opts.onSpotlight?.(null);
75
+ renderer.close();
76
+ this.active = undefined;
77
+ if (!state.seen.includes(scriptId)) {
78
+ state.seen.push(scriptId);
79
+ void this.persist();
80
+ }
81
+ resolve(true);
82
+ },
83
+ };
84
+ this.active = new DialogueRunner(script, host, tr);
85
+ this.active.start();
86
+ });
87
+ }
88
+ /** Has the player already finished this script? (once-only intro checks). */
89
+ async hasSeen(scriptId) {
90
+ return (await this.ensureState()).seen.includes(scriptId);
91
+ }
92
+ /** Mark a script seen WITHOUT playing it (e.g. skip an intro for a returning save). */
93
+ async markSeen(scriptId) {
94
+ const s = await this.ensureState();
95
+ if (!s.seen.includes(scriptId)) {
96
+ s.seen.push(scriptId);
97
+ await this.persist();
98
+ }
99
+ }
100
+ /** Read a persistent dialogue flag. */
101
+ async getFlag(flag) {
102
+ return (await this.ensureState()).flags[flag] === true;
103
+ }
104
+ /** Set a persistent dialogue flag. */
105
+ async setFlag(flag, value = true) {
106
+ const s = await this.ensureState();
107
+ s.flags[flag] = value;
108
+ await this.persist();
109
+ }
110
+ /** Forget all seen-state + flags (a fresh-start / debug reset). */
111
+ async reset() {
112
+ this.state = { seen: [], flags: {} };
113
+ await this.persist();
114
+ }
115
+ // --- internals -----------------------------------------------------------
116
+ async ensureState() {
117
+ if (this.state)
118
+ return this.state;
119
+ if (!this.loading) {
120
+ this.loading = this.saves
121
+ .get(SAVE_KEY)
122
+ .then((raw) => ({ seen: raw?.seen ?? [], flags: raw?.flags ?? {} }))
123
+ .catch(() => ({ seen: [], flags: {} }));
124
+ }
125
+ this.state = await this.loading;
126
+ return this.state;
127
+ }
128
+ async persist() {
129
+ if (!this.state)
130
+ return;
131
+ try {
132
+ await this.saves.set(SAVE_KEY, this.state);
133
+ }
134
+ catch { /* best-effort */ }
135
+ }
136
+ async loadScript(scriptId) {
137
+ const cached = this.scripts.get(scriptId);
138
+ if (cached)
139
+ return cached;
140
+ try {
141
+ const res = await fetch(bustCache(`${DIALOGUE_DIR}/${scriptId}.json`));
142
+ if (!res.ok)
143
+ return null;
144
+ const script = (await res.json());
145
+ this.scripts.set(scriptId, script);
146
+ return script;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ }
153
+ /** Replace `{key}` placeholders from a substitution map (unknown keys left as-is). */
154
+ function substitute(text, vars) {
155
+ return text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
156
+ }
@@ -0,0 +1,108 @@
1
+ /** A localized string: a plain string, or a per-locale map (`{ en, 'zh-CN' }`). */
2
+ export type LangText = string | Record<string, string>;
3
+ export type DialogueNodeType = 'line' | 'choice' | 'set' | 'if' | 'end';
4
+ interface NodeBase {
5
+ type: DialogueNodeType;
6
+ /** Editor-only canvas position (ignored at runtime). */
7
+ x?: number;
8
+ y?: number;
9
+ }
10
+ export interface DialogueLineNode extends NodeBase {
11
+ type: 'line';
12
+ /** Speaker id (e.g. 'npc') — the host maps it to a display name + portrait. */
13
+ speaker?: string;
14
+ /** Mood tag the host may map to a portrait/emote. */
15
+ emote?: string;
16
+ text: LangText;
17
+ /**
18
+ * Free-form, GAME-interpreted key/values attached to this line (the generic
19
+ * extension point). The runner passes them to the host verbatim; YOUR game
20
+ * decides what they mean — e.g. `{ spotlight: 'hotbar:hoe', sound: 'meow',
21
+ * camera: 'well' }`. Authored in the Dialogue tool's "Custom data" editor.
22
+ */
23
+ data?: Record<string, string>;
24
+ /** @deprecated Legacy top-level convenience — prefer `data.spotlight`. Still
25
+ * read as a fallback (folded into `data`) so old scripts keep working. */
26
+ spotlight?: string;
27
+ next?: string;
28
+ }
29
+ export interface DialogueChoiceOption {
30
+ text: LangText;
31
+ next?: string;
32
+ set?: string;
33
+ }
34
+ export interface DialogueChoiceNode extends NodeBase {
35
+ type: 'choice';
36
+ speaker?: string;
37
+ text?: LangText;
38
+ /** Free-form, game-interpreted key/values for this node (see DialogueLineNode.data). */
39
+ data?: Record<string, string>;
40
+ options: DialogueChoiceOption[];
41
+ }
42
+ export interface DialogueSetNode extends NodeBase {
43
+ type: 'set';
44
+ flag: string;
45
+ value?: boolean;
46
+ next?: string;
47
+ }
48
+ export interface DialogueIfNode extends NodeBase {
49
+ type: 'if';
50
+ flag: string;
51
+ then?: string;
52
+ else?: string;
53
+ }
54
+ export interface DialogueEndNode extends NodeBase {
55
+ type: 'end';
56
+ }
57
+ export type DialogueNode = DialogueLineNode | DialogueChoiceNode | DialogueSetNode | DialogueIfNode | DialogueEndNode;
58
+ export interface DialogueScript {
59
+ id: string;
60
+ /** 'new-game' plays once on a fresh save (game decides when to check); 'manual' = code calls it. */
61
+ trigger?: 'new-game' | 'manual';
62
+ start: string;
63
+ nodes: Record<string, DialogueNode>;
64
+ }
65
+ /** What the runner needs a renderer to do. The built-in box implements this; a game
66
+ * can supply its own to reuse its themed UI. */
67
+ export interface DialogueHost {
68
+ /** Show a spoken line; call `runner.advance()` when the player continues.
69
+ * `data` = the node's game-interpreted key/values (spotlight folded in). */
70
+ showLine(text: string, opts: {
71
+ speaker?: string;
72
+ emote?: string;
73
+ spotlight?: string | null;
74
+ data?: Record<string, string>;
75
+ }): void;
76
+ /** Show choices; call `pick(i)` with the chosen option index. */
77
+ showChoices(prompt: string | null, options: string[], pick: (i: number) => void, opts?: {
78
+ speaker?: string;
79
+ data?: Record<string, string>;
80
+ }): void;
81
+ getFlag(flag: string): boolean;
82
+ setFlag(flag: string, value: boolean): void;
83
+ /** Script ended — tear the UI down. */
84
+ finish(): void;
85
+ }
86
+ export declare class DialogueRunner {
87
+ private readonly script;
88
+ private readonly host;
89
+ /** Resolve a LangText → a display string (locale + `{name}` substitution live here). */
90
+ private readonly tr;
91
+ private current?;
92
+ private done;
93
+ constructor(script: DialogueScript, host: DialogueHost,
94
+ /** Resolve a LangText → a display string (locale + `{name}` substitution live here). */
95
+ tr: (t: LangText) => string);
96
+ get isDone(): boolean;
97
+ get id(): string;
98
+ start(): void;
99
+ /** Force the script to end early (e.g. a new dialogue is starting). Idempotent. */
100
+ stop(): void;
101
+ /** Player advanced past the current LINE → go to its `next` (choices advance via pick). */
102
+ advance(): void;
103
+ private go;
104
+ private end;
105
+ }
106
+ /** Resolve a LangText to the active locale, falling back to `en` → the first value. */
107
+ export declare function resolveLangText(text: LangText, locale: string): string;
108
+ export {};
@@ -0,0 +1,80 @@
1
+ // Scripted-dialogue core — AUTHORED (non-AI) conversations: cutscenes, intros,
2
+ // tutorials, branching talks. Deterministic, hand-written, i18n. Distinct from
3
+ // runtime-AI chat (`umicat.ai`). A script is a graph of nodes; DialogueRunner walks
4
+ // it and calls a HOST (a renderer) to show lines / choices / spotlights + read/write
5
+ // flags. Host-agnostic so the same runner drives the built-in box OR a custom UI.
6
+ //
7
+ // The data format is `public/dialogue/<id>.json` — authored in the Dialogue tool.
8
+ export class DialogueRunner {
9
+ constructor(script, host,
10
+ /** Resolve a LangText → a display string (locale + `{name}` substitution live here). */
11
+ tr) {
12
+ this.script = script;
13
+ this.host = host;
14
+ this.tr = tr;
15
+ this.done = false;
16
+ }
17
+ get isDone() { return this.done; }
18
+ get id() { return this.script.id; }
19
+ start() { this.go(this.script.start); }
20
+ /** Force the script to end early (e.g. a new dialogue is starting). Idempotent. */
21
+ stop() { this.end(); }
22
+ /** Player advanced past the current LINE → go to its `next` (choices advance via pick). */
23
+ advance() {
24
+ const n = this.current ? this.script.nodes[this.current] : undefined;
25
+ if (n && n.type === 'line')
26
+ this.go(n.next);
27
+ }
28
+ go(id) {
29
+ if (this.done)
30
+ return;
31
+ if (!id)
32
+ return this.end();
33
+ const n = this.script.nodes[id];
34
+ if (!n)
35
+ return this.end();
36
+ this.current = id;
37
+ switch (n.type) {
38
+ case 'line': {
39
+ const data = mergeNodeData(n);
40
+ this.host.showLine(this.tr(n.text), {
41
+ speaker: n.speaker, emote: n.emote, spotlight: data.spotlight ?? null, data,
42
+ });
43
+ return;
44
+ }
45
+ case 'choice': {
46
+ const data = mergeNodeData(n);
47
+ this.host.showChoices(n.text ? this.tr(n.text) : null, n.options.map((o) => this.tr(o.text)), (i) => { const o = n.options[i]; if (o?.set)
48
+ this.host.setFlag(o.set, true); this.go(o?.next); }, { speaker: n.speaker, data });
49
+ return;
50
+ }
51
+ case 'set':
52
+ this.host.setFlag(n.flag, n.value !== false);
53
+ return this.go(n.next);
54
+ case 'if':
55
+ return this.go(this.host.getFlag(n.flag) ? n.then : n.else);
56
+ case 'end':
57
+ default:
58
+ return this.end();
59
+ }
60
+ }
61
+ end() {
62
+ if (this.done)
63
+ return;
64
+ this.done = true;
65
+ this.host.finish();
66
+ }
67
+ }
68
+ /** Merge a node's game-interpreted data, folding the legacy top-level `spotlight`
69
+ * in (explicit `data` wins). Always returns an object (never undefined). */
70
+ function mergeNodeData(n) {
71
+ return { ...(n.spotlight ? { spotlight: n.spotlight } : {}), ...(n.data ?? {}) };
72
+ }
73
+ /** Resolve a LangText to the active locale, falling back to `en` → the first value. */
74
+ export function resolveLangText(text, locale) {
75
+ if (typeof text === 'string')
76
+ return text;
77
+ if (!text)
78
+ return '';
79
+ return text[locale] ?? text.en ?? Object.values(text)[0] ?? '';
80
+ }
@@ -0,0 +1,45 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ /**
3
+ * Game-scope key-value store shared by all players of a game.
4
+ *
5
+ * Unlike `umicat.saves` (per-user), `gameData` lives at the game level — one
6
+ * value per key, visible to everyone. Reads are public (anonymous players
7
+ * see the same data). Writes require an authenticated user; calling
8
+ * `set()` or `delete()` when `umicat.user === null` throws an `RpcError`
9
+ * with code `UNAUTHENTICATED`.
10
+ *
11
+ * Typical uses: scoreboards, shared inventories, tournament state, level
12
+ * of the day. Values are opaque JSON — primitive, object, or collection.
13
+ *
14
+ * Trust model (see SDK-GUIDE.md for details):
15
+ * - The backend does not enforce invariants INSIDE a value. If you store
16
+ * a list and append your entry, you own the read-modify-write loop —
17
+ * including not mutating other players' entries, truncating to cap
18
+ * the list size, and retrying on 409 conflicts.
19
+ * - Use `ifVersion` to avoid lost updates when concurrent writes race.
20
+ *
21
+ * Quotas (enforced at the backend):
22
+ * - 100 KB per value
23
+ * - 1 MB total per game
24
+ * - 64 keys per game
25
+ */
26
+ export declare class GameDataModule {
27
+ private transport;
28
+ constructor(transport: Transport);
29
+ /** Read the value under `key`. Returns `null` if unset. Public — works for anonymous viewers. */
30
+ get<T = unknown>(key: string): Promise<T | null>;
31
+ /**
32
+ * Write `value` under `key`. Requires an authenticated user.
33
+ * @param options.ifVersion — only succeed if the stored version matches; otherwise throws
34
+ * an RpcError with code `VERSION_MISMATCH`. Use this to implement safe read-modify-write
35
+ * loops for list values (scoreboards, shared lists).
36
+ * @returns the new version number.
37
+ */
38
+ set(key: string, value: unknown, options?: {
39
+ ifVersion?: number;
40
+ }): Promise<number>;
41
+ /** Delete the value at `key`. Requires an authenticated user. */
42
+ delete(key: string): Promise<boolean>;
43
+ /** List all keys that have a value set for this game. Public. */
44
+ list(): Promise<string[]>;
45
+ }