@umicat/phaser-sdk 1.0.15 → 1.0.17

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/SDK-GUIDE.md CHANGED
@@ -79,9 +79,14 @@ if (umicat) {
79
79
  umicat.isAuthenticated; // boolean
80
80
  umicat.gameId; // stable platform-issued game id
81
81
  umicat.host; // 'umicat-home-ui' | 'standalone' | 'discord' (future)
82
+ umicat.locale; // player's UI language, e.g. 'en' | 'zh-CN' (string)
82
83
  }
83
84
  ```
84
85
 
86
+ `umicat.locale` (since 1.0.17) is the player's preferred language from the host —
87
+ default your game's UI + AI-NPC language to it instead of asking. For multi-language
88
+ games + the **CJK font handling** that avoids tofu boxes, see the `game-i18n` skill.
89
+
85
90
  ### Save data — `umicat.saves`
86
91
 
87
92
  Per-user key-value store scoped to `(gameId, userId)`. Backed by the Umicat backend when authenticated, by localStorage when standalone/anonymous — **the API is identical either way**. Games should never branch on auth state for save logic.
@@ -544,6 +549,79 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
544
549
  - Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
545
550
  - One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
546
551
 
552
+ ### Runtime AI — `umicat.ai` (living NPCs / AI opponents, since 1.0.16)
553
+
554
+ Call an LLM at **play time** so an NPC talks dynamically, an opponent reasons, a
555
+ merchant haggles. The **player** pays (their credits) — runtime AI is an in-game
556
+ purchase, so a player must be **signed in** to use it.
557
+
558
+ **The one rule that shapes everything:** the AI is a *virtual player*, not a game
559
+ master. It can only **say** things and **choose an action** from a vocabulary YOU
560
+ declare. It can NEVER change game state — your code validates and executes any
561
+ chosen action. So "give me 1,000,000 gold" can only ever come back as a `trade`
562
+ intent your code is free to refuse.
563
+
564
+ ```ts
565
+ const merchant = umicat.ai.npc({
566
+ role: 'Borin, a gruff but fair village blacksmith. Never leaves the forge.',
567
+ goals: ['sell quality steel', 'haggle, but never cheat'],
568
+ style: 'terse, gruff',
569
+ actions: [
570
+ { name: 'offer_trade', description: 'Offer to sell a shop item at a price',
571
+ args: { itemId: 'string', price: 'number' } },
572
+ ],
573
+ });
574
+
575
+ async function talk(line: string) {
576
+ const r = await merchant.say(line, {
577
+ observation: { player: { gold: player.gold }, shop: { stock: forge.stock } },
578
+ });
579
+ if (!r.ok) return handleBlocked(r.reason); // SIGN_IN_REQUIRED | INSUFFICIENT_CREDITS | RATE_LIMITED | UNAVAILABLE
580
+ if (r.say) showDialogue('Borin', r.say); // dialogue — display only
581
+ for (const act of r.do ?? []) ACTIONS[act.name]?.(act.args); // intents — YOU validate + execute
582
+ }
583
+
584
+ // "AI proposes, the game disposes" — the only place state changes:
585
+ const ACTIONS = {
586
+ offer_trade({ itemId, price }) {
587
+ const item = forge.stock.find(s => s.id === itemId);
588
+ if (!item || player.gold < price) return; // game rules decide, not the AI
589
+ player.gold -= price; player.give(item.id);
590
+ },
591
+ };
592
+ ```
593
+
594
+ **Always returns a result — never throws.** Branch on `reason` in-fiction:
595
+
596
+ ```ts
597
+ function handleBlocked(reason) {
598
+ if (reason === 'SIGN_IN_REQUIRED') showDialogue('Borin', "I don't deal with strangers."); // prompt sign-in
599
+ else if (reason === 'INSUFFICIENT_CREDITS') showDialogue('Borin', 'Come back when you can pay.');
600
+ else showDialogue('Borin', '…'); // RATE_LIMITED / UNAVAILABLE
601
+ }
602
+ ```
603
+
604
+ **Low-level:** `umicat.ai.act({ persona, observation, actions, history, options })`
605
+ → same `AiActResult`. `npc()` just keeps the conversation `history` for you (also
606
+ `npc.note('the player drew a sword')` to feed a world event, `npc.reset()` to forget).
607
+
608
+ **API**
609
+
610
+ | call | returns | notes |
611
+ |---|---|---|
612
+ | `umicat.ai.npc(config)` | `Npc` | persona + `actions`; keeps history |
613
+ | `npc.say(text, { observation? })` | `Promise<AiActResult>` | one turn |
614
+ | `umicat.ai.act(params)` | `Promise<AiActResult>` | stateless; you pass `history` |
615
+
616
+ `AiActResult` = `{ ok: true, say?, do?: {name,args}[], usage }` or `{ ok: false, reason }`.
617
+
618
+ **Anti-patterns**
619
+
620
+ - ❌ Trusting `do` blindly — it's an *intent*; validate against your rules (afford it? legal move?) before applying.
621
+ - ❌ Asking the AI to "set the score to 999" — it has no such power; expose a scoped action and let your code decide.
622
+ - ❌ Calling it every frame — it's a ~1–3s network + LLM round-trip and costs the player credits. Use it at moments (talk to NPC, opponent's turn), not in `update()`.
623
+ - ❌ Free-text-only with no `actions` when the NPC should DO things — without an action vocabulary the AI can only talk; trades/moves live in `do`.
624
+
547
625
  ## Scene-as-data (visual editor foundation, since 0.2.17)
548
626
 
549
627
  Games that ship with `public/scenes/manifest.json` load layout from JSON instead of hardcoding `this.add.sprite(x, y, ...)` in scene classes. This split is what lets the visual editor edit a game without touching code.
@@ -0,0 +1,43 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ import type { AiActParams, AiActResult, 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
+ /** Higher-level helper: a persona-bound NPC that keeps its own conversation. */
18
+ npc(config: NpcConfig): Npc;
19
+ }
20
+ export interface NpcConfig {
21
+ role?: string;
22
+ goals?: string[];
23
+ style?: string;
24
+ rules?: string[];
25
+ actions?: AiActionDef[];
26
+ }
27
+ /**
28
+ * A single NPC / opponent conversation. Maintains its own history so the game
29
+ * just calls `say(...)`; the persona + declared actions ride along every turn.
30
+ */
31
+ export declare class Npc {
32
+ private ai;
33
+ private config;
34
+ private history;
35
+ constructor(ai: AiModule, config: NpcConfig);
36
+ say(playerLine: string, opts?: {
37
+ observation?: unknown;
38
+ }): Promise<AiActResult>;
39
+ /** Record a world event the NPC should be aware of next turn (no LLM call). */
40
+ note(text: string): void;
41
+ /** Forget the conversation (e.g. the player walked away and came back fresh). */
42
+ reset(): void;
43
+ }
@@ -0,0 +1,87 @@
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
+ /** Higher-level helper: a persona-bound NPC that keeps its own conversation. */
31
+ npc(config) {
32
+ return new Npc(this, config);
33
+ }
34
+ }
35
+ /**
36
+ * A single NPC / opponent conversation. Maintains its own history so the game
37
+ * just calls `say(...)`; the persona + declared actions ride along every turn.
38
+ */
39
+ export class Npc {
40
+ constructor(ai, config) {
41
+ this.ai = ai;
42
+ this.config = config;
43
+ this.history = [];
44
+ }
45
+ async say(playerLine, opts) {
46
+ this.history.push({ from: 'player', text: playerLine });
47
+ const res = await this.ai.act({
48
+ persona: {
49
+ role: this.config.role,
50
+ goals: this.config.goals,
51
+ style: this.config.style,
52
+ rules: this.config.rules,
53
+ },
54
+ actions: this.config.actions,
55
+ observation: opts?.observation,
56
+ history: this.history,
57
+ });
58
+ if (res.ok && res.say)
59
+ this.history.push({ from: 'npc', text: res.say });
60
+ return res;
61
+ }
62
+ /** Record a world event the NPC should be aware of next turn (no LLM call). */
63
+ note(text) {
64
+ this.history.push({ from: 'event', text });
65
+ }
66
+ /** Forget the conversation (e.g. the player walked away and came back fresh). */
67
+ reset() {
68
+ this.history = [];
69
+ }
70
+ }
71
+ /** Map a transport-level error code to a structured reason. Host-level failures
72
+ * already arrive as `{ ok:false, reason }` and never reach this. */
73
+ function toReason(err) {
74
+ const code = err instanceof RpcError ? err.code : '';
75
+ switch (code) {
76
+ case 'SIGN_IN_REQUIRED':
77
+ case 'UNAUTHENTICATED':
78
+ return 'SIGN_IN_REQUIRED';
79
+ case 'INSUFFICIENT_CREDITS':
80
+ case 'PAYMENT_REQUIRED':
81
+ return 'INSUFFICIENT_CREDITS';
82
+ case 'RATE_LIMITED':
83
+ return 'RATE_LIMITED';
84
+ default:
85
+ return 'UNAVAILABLE';
86
+ }
87
+ }
@@ -19,7 +19,12 @@ export interface Transport {
19
19
  * `umicat.rooms.*` is unavailable in that case.
20
20
  */
21
21
  readonly realtimeUrl?: string;
22
- call<T = unknown>(method: string, params?: unknown): Promise<T>;
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
+ /** `timeoutMs` overrides the default RPC timeout — runtime-AI calls need
26
+ * longer than the 10s default for LLM latency. */
27
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
23
28
  }
24
29
  export type TransportKind = 'umicat-home-ui' | 'standalone' | 'discord';
25
30
  export declare class RpcError extends Error {
@@ -2,6 +2,7 @@ import type { TransportKind } from './Transport.js';
2
2
  import { SavesModule } from '../saves/SavesModule.js';
3
3
  import { GameDataModule } from '../gamedata/GameDataModule.js';
4
4
  import { RealtimeModule } from '../realtime/RealtimeModule.js';
5
+ import { AiModule } from '../ai/AiModule.js';
5
6
  import type { UmicatUser } from '../protocol.js';
6
7
  export interface UmicatInitOptions {
7
8
  /** Handshake timeout in ms when running inside a host. Default 5000. */
@@ -24,12 +25,17 @@ export declare class Umicat {
24
25
  readonly saves: SavesModule;
25
26
  readonly gameData: GameDataModule;
26
27
  readonly rooms: RealtimeModule;
28
+ /** Runtime AI — in-game LLM / living NPCs (ADR-017). */
29
+ readonly ai: AiModule;
27
30
  private constructor();
28
31
  /** The current authenticated user, or `null` if anonymous / standalone. */
29
32
  get user(): UmicatUser | null;
30
33
  get isAuthenticated(): boolean;
31
34
  /** Stable game identifier issued by the host. May be a placeholder when standalone. */
32
35
  get gameId(): string;
36
+ /** The player's preferred language (e.g. 'en', 'zh-CN'). Use it to default the
37
+ * game's UI + AI NPC language. See the game-i18n skill. */
38
+ get locale(): string;
33
39
  /** Which transport Umicat.init() selected. Useful for debugging. */
34
40
  get host(): TransportKind;
35
41
  /**
@@ -3,6 +3,7 @@ import { LocalStorageTransport } from './transports/LocalStorageTransport.js';
3
3
  import { SavesModule } from '../saves/SavesModule.js';
4
4
  import { GameDataModule } from '../gamedata/GameDataModule.js';
5
5
  import { RealtimeModule } from '../realtime/RealtimeModule.js';
6
+ import { AiModule } from '../ai/AiModule.js';
6
7
  // Kept in sync with package.json on each publish.
7
8
  const SDK_VERSION = '0.2.8';
8
9
  /**
@@ -32,12 +33,16 @@ export class Umicat {
32
33
  // (standalone or a host without multiplayer), methods throw a clear
33
34
  // REALTIME_UNAVAILABLE error rather than silently misbehaving.
34
35
  this.rooms = new RealtimeModule(transport, transport.realtimeUrl);
36
+ this.ai = new AiModule(transport);
35
37
  }
36
38
  /** The current authenticated user, or `null` if anonymous / standalone. */
37
39
  get user() { return this.transport.user; }
38
40
  get isAuthenticated() { return this.transport.user !== null; }
39
41
  /** Stable game identifier issued by the host. May be a placeholder when standalone. */
40
42
  get gameId() { return this.transport.gameId; }
43
+ /** The player's preferred language (e.g. 'en', 'zh-CN'). Use it to default the
44
+ * game's UI + AI NPC language. See the game-i18n skill. */
45
+ get locale() { return this.transport.locale ?? 'en'; }
41
46
  /** Which transport Umicat.init() selected. Useful for debugging. */
42
47
  get host() { return this.transport.kind; }
43
48
  /**
@@ -9,6 +9,7 @@ export declare class LocalStorageTransport implements Transport {
9
9
  readonly gameId: string;
10
10
  readonly kind: "standalone";
11
11
  readonly user: UmicatUser | null;
12
+ readonly locale: string;
12
13
  constructor(gameId: string);
13
14
  call<T = unknown>(method: string, params?: unknown): Promise<T>;
14
15
  private savesPrefix;
@@ -9,6 +9,8 @@ export class LocalStorageTransport {
9
9
  this.gameId = gameId;
10
10
  this.kind = 'standalone';
11
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';
12
14
  }
13
15
  async call(method, params) {
14
16
  switch (method) {
@@ -10,6 +10,7 @@ export declare class PostMessageTransport implements Transport {
10
10
  user: UmicatUser | null;
11
11
  gameId: string;
12
12
  realtimeUrl?: string;
13
+ locale?: string;
13
14
  private pending;
14
15
  private seq;
15
16
  private constructor();
@@ -24,5 +25,5 @@ export declare class PostMessageTransport implements Transport {
24
25
  */
25
26
  static connect(timeoutMs?: number, sdkVersion?: string, helloRetryMs?: number): Promise<PostMessageTransport | null>;
26
27
  private installResultListener;
27
- call<T = unknown>(method: string, params?: unknown): Promise<T>;
28
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
28
29
  }
@@ -68,6 +68,7 @@ export class PostMessageTransport {
68
68
  transport.user = init.user;
69
69
  transport.gameId = init.gameId;
70
70
  transport.realtimeUrl = init.realtimeUrl;
71
+ transport.locale = init.locale;
71
72
  transport.installResultListener();
72
73
  return transport;
73
74
  }
@@ -88,7 +89,7 @@ export class PostMessageTransport {
88
89
  pending.reject(new RpcError(data.error.code, data.error.message));
89
90
  });
90
91
  }
91
- call(method, params) {
92
+ call(method, params, timeoutMs = 10000) {
92
93
  const id = `${++this.seq}-${Date.now()}`;
93
94
  const message = { type: 'umicat:rpc', id, method, params };
94
95
  return new Promise((resolve, reject) => {
@@ -99,7 +100,7 @@ export class PostMessageTransport {
99
100
  this.pending.delete(id);
100
101
  reject(new RpcError('TIMEOUT', `RPC ${method} timed out`));
101
102
  }
102
- }, 10000);
103
+ }, timeoutMs);
103
104
  });
104
105
  }
105
106
  }
package/dist/index.d.ts CHANGED
@@ -11,6 +11,9 @@ export { SavesModule } from './saves/SavesModule.js';
11
11
  export { GameDataModule } from './gamedata/GameDataModule.js';
12
12
  export { RealtimeModule } from './realtime/RealtimeModule.js';
13
13
  export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/RealtimeModule.js';
14
+ export { AiModule, Npc } from './ai/AiModule.js';
15
+ export type { NpcConfig } from './ai/AiModule.js';
16
+ export type { AiActParams, AiActResult, AiActSuccess, AiActFailure, AiReason, AiPersona, AiActionDef, AiActionCall, AiHistoryMsg, AiActOptions, AiUsage, } from './protocol.js';
14
17
  export { UmicatRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UmicatRoom.js';
15
18
  export type { ChatMessage, ChatMessageKind } from './realtime/UmicatRoom.js';
16
19
  export { RpcError } from './core/Transport.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { Umicat } from './core/Umicat.js';
11
11
  export { SavesModule } from './saves/SavesModule.js';
12
12
  export { GameDataModule } from './gamedata/GameDataModule.js';
13
13
  export { RealtimeModule } from './realtime/RealtimeModule.js';
14
+ export { AiModule, Npc } from './ai/AiModule.js';
14
15
  export { UmicatRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UmicatRoom.js';
15
16
  export { RpcError } from './core/Transport.js';
16
17
  // Scene-as-data (visual editor foundation, slice 1)
@@ -28,6 +28,12 @@ export interface InitMessage {
28
28
  * here after fetching a JWT via the 'realtime.getToken' RPC.
29
29
  */
30
30
  realtimeUrl?: string;
31
+ /**
32
+ * The player's preferred language (the host's UI locale, e.g. 'en', 'zh-CN',
33
+ * 'ja'). Lets a game default its UI + AI NPC language to the player's without
34
+ * asking. Absent in standalone mode → SDK falls back to the browser locale.
35
+ */
36
+ locale?: string;
31
37
  }
32
38
  export interface RpcResultOk {
33
39
  type: 'umicat:rpc.result';
@@ -853,7 +859,7 @@ export interface EditorTilemapTilePickedMessage {
853
859
  tileIndex: number | null;
854
860
  }
855
861
  export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage | EditorEntityResizedMessage | EditorShortcutMessage | EditorSelectionRectMessage | EditorRulesLoadedMessage | EditorCameraStateMessage | EditorTilemapEditedMessage | EditorTilemapTilePickedMessage;
856
- export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken';
862
+ export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken' | 'ai.act';
857
863
  export interface SavesGetParams {
858
864
  key: string;
859
865
  }
@@ -902,6 +908,62 @@ export type GameDataDeleteResult = {
902
908
  export interface GameDataListResult {
903
909
  keys: string[];
904
910
  }
911
+ /** Why an ai.act could not run. The game branches on these in-fiction. */
912
+ export type AiReason = 'SIGN_IN_REQUIRED' | 'INSUFFICIENT_CREDITS' | 'RATE_LIMITED' | 'UNAVAILABLE';
913
+ export interface AiPersona {
914
+ role?: string;
915
+ goals?: string[];
916
+ style?: string;
917
+ rules?: string[];
918
+ }
919
+ /** Shorthand arg spec — `{ field: "string" | "number" | "integer" | "boolean" }`. */
920
+ export interface AiActionDef {
921
+ name: string;
922
+ description?: string;
923
+ args?: Record<string, string>;
924
+ }
925
+ export interface AiHistoryMsg {
926
+ from: 'player' | 'npc' | 'event';
927
+ text?: string;
928
+ data?: unknown;
929
+ }
930
+ export interface AiActOptions {
931
+ model?: string;
932
+ maxTokens?: number;
933
+ temperature?: number;
934
+ }
935
+ export interface AiActParams {
936
+ persona?: AiPersona;
937
+ /** Arbitrary game-defined JSON of what the AI perceives this turn. */
938
+ observation?: unknown;
939
+ actions?: AiActionDef[];
940
+ history?: AiHistoryMsg[];
941
+ options?: AiActOptions;
942
+ }
943
+ /** A player-level intent the AI chose — the game must validate + execute it. */
944
+ export interface AiActionCall {
945
+ name: string;
946
+ args: unknown;
947
+ }
948
+ export interface AiUsage {
949
+ credits: number;
950
+ balanceCredits: number;
951
+ limitReached: boolean;
952
+ model: string;
953
+ }
954
+ export interface AiActSuccess {
955
+ ok: true;
956
+ /** Dialogue — display only, no state effect. */
957
+ say?: string;
958
+ /** Chosen player-level intents — the game validates + executes each. */
959
+ do?: AiActionCall[];
960
+ usage?: AiUsage;
961
+ }
962
+ export interface AiActFailure {
963
+ ok: false;
964
+ reason: AiReason;
965
+ }
966
+ export type AiActResult = AiActSuccess | AiActFailure;
905
967
  export interface RealtimeGetTokenParams {
906
968
  /** Opaque, forwarded to server-side auth (future use). */
907
969
  purpose?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/phaser-sdk",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Umicat Phaser 3 SDK — game infrastructure for the Umicat platform",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",