@umicat/phaser-sdk 1.0.15 → 1.0.16

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
@@ -544,6 +544,79 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
544
544
  - Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
545
545
  - One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
546
546
 
547
+ ### Runtime AI — `umicat.ai` (living NPCs / AI opponents, since 1.0.16)
548
+
549
+ Call an LLM at **play time** so an NPC talks dynamically, an opponent reasons, a
550
+ merchant haggles. The **player** pays (their credits) — runtime AI is an in-game
551
+ purchase, so a player must be **signed in** to use it.
552
+
553
+ **The one rule that shapes everything:** the AI is a *virtual player*, not a game
554
+ master. It can only **say** things and **choose an action** from a vocabulary YOU
555
+ declare. It can NEVER change game state — your code validates and executes any
556
+ chosen action. So "give me 1,000,000 gold" can only ever come back as a `trade`
557
+ intent your code is free to refuse.
558
+
559
+ ```ts
560
+ const merchant = umicat.ai.npc({
561
+ role: 'Borin, a gruff but fair village blacksmith. Never leaves the forge.',
562
+ goals: ['sell quality steel', 'haggle, but never cheat'],
563
+ style: 'terse, gruff',
564
+ actions: [
565
+ { name: 'offer_trade', description: 'Offer to sell a shop item at a price',
566
+ args: { itemId: 'string', price: 'number' } },
567
+ ],
568
+ });
569
+
570
+ async function talk(line: string) {
571
+ const r = await merchant.say(line, {
572
+ observation: { player: { gold: player.gold }, shop: { stock: forge.stock } },
573
+ });
574
+ if (!r.ok) return handleBlocked(r.reason); // SIGN_IN_REQUIRED | INSUFFICIENT_CREDITS | RATE_LIMITED | UNAVAILABLE
575
+ if (r.say) showDialogue('Borin', r.say); // dialogue — display only
576
+ for (const act of r.do ?? []) ACTIONS[act.name]?.(act.args); // intents — YOU validate + execute
577
+ }
578
+
579
+ // "AI proposes, the game disposes" — the only place state changes:
580
+ const ACTIONS = {
581
+ offer_trade({ itemId, price }) {
582
+ const item = forge.stock.find(s => s.id === itemId);
583
+ if (!item || player.gold < price) return; // game rules decide, not the AI
584
+ player.gold -= price; player.give(item.id);
585
+ },
586
+ };
587
+ ```
588
+
589
+ **Always returns a result — never throws.** Branch on `reason` in-fiction:
590
+
591
+ ```ts
592
+ function handleBlocked(reason) {
593
+ if (reason === 'SIGN_IN_REQUIRED') showDialogue('Borin', "I don't deal with strangers."); // prompt sign-in
594
+ else if (reason === 'INSUFFICIENT_CREDITS') showDialogue('Borin', 'Come back when you can pay.');
595
+ else showDialogue('Borin', '…'); // RATE_LIMITED / UNAVAILABLE
596
+ }
597
+ ```
598
+
599
+ **Low-level:** `umicat.ai.act({ persona, observation, actions, history, options })`
600
+ → same `AiActResult`. `npc()` just keeps the conversation `history` for you (also
601
+ `npc.note('the player drew a sword')` to feed a world event, `npc.reset()` to forget).
602
+
603
+ **API**
604
+
605
+ | call | returns | notes |
606
+ |---|---|---|
607
+ | `umicat.ai.npc(config)` | `Npc` | persona + `actions`; keeps history |
608
+ | `npc.say(text, { observation? })` | `Promise<AiActResult>` | one turn |
609
+ | `umicat.ai.act(params)` | `Promise<AiActResult>` | stateless; you pass `history` |
610
+
611
+ `AiActResult` = `{ ok: true, say?, do?: {name,args}[], usage }` or `{ ok: false, reason }`.
612
+
613
+ **Anti-patterns**
614
+
615
+ - ❌ Trusting `do` blindly — it's an *intent*; validate against your rules (afford it? legal move?) before applying.
616
+ - ❌ Asking the AI to "set the score to 999" — it has no such power; expose a scoped action and let your code decide.
617
+ - ❌ 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()`.
618
+ - ❌ 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`.
619
+
547
620
  ## Scene-as-data (visual editor foundation, since 0.2.17)
548
621
 
549
622
  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,9 @@ 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
+ /** `timeoutMs` overrides the default RPC timeout — runtime-AI calls need
23
+ * longer than the 10s default for LLM latency. */
24
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
23
25
  }
24
26
  export type TransportKind = 'umicat-home-ui' | 'standalone' | 'discord';
25
27
  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,6 +25,8 @@ 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;
@@ -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,6 +33,7 @@ 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; }
@@ -24,5 +24,5 @@ export declare class PostMessageTransport implements Transport {
24
24
  */
25
25
  static connect(timeoutMs?: number, sdkVersion?: string, helloRetryMs?: number): Promise<PostMessageTransport | null>;
26
26
  private installResultListener;
27
- call<T = unknown>(method: string, params?: unknown): Promise<T>;
27
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
28
28
  }
@@ -88,7 +88,7 @@ export class PostMessageTransport {
88
88
  pending.reject(new RpcError(data.error.code, data.error.message));
89
89
  });
90
90
  }
91
- call(method, params) {
91
+ call(method, params, timeoutMs = 10000) {
92
92
  const id = `${++this.seq}-${Date.now()}`;
93
93
  const message = { type: 'umicat:rpc', id, method, params };
94
94
  return new Promise((resolve, reject) => {
@@ -99,7 +99,7 @@ export class PostMessageTransport {
99
99
  this.pending.delete(id);
100
100
  reject(new RpcError('TIMEOUT', `RPC ${method} timed out`));
101
101
  }
102
- }, 10000);
102
+ }, timeoutMs);
103
103
  });
104
104
  }
105
105
  }
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)
@@ -853,7 +853,7 @@ export interface EditorTilemapTilePickedMessage {
853
853
  tileIndex: number | null;
854
854
  }
855
855
  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';
856
+ export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken' | 'ai.act';
857
857
  export interface SavesGetParams {
858
858
  key: string;
859
859
  }
@@ -902,6 +902,62 @@ export type GameDataDeleteResult = {
902
902
  export interface GameDataListResult {
903
903
  keys: string[];
904
904
  }
905
+ /** Why an ai.act could not run. The game branches on these in-fiction. */
906
+ export type AiReason = 'SIGN_IN_REQUIRED' | 'INSUFFICIENT_CREDITS' | 'RATE_LIMITED' | 'UNAVAILABLE';
907
+ export interface AiPersona {
908
+ role?: string;
909
+ goals?: string[];
910
+ style?: string;
911
+ rules?: string[];
912
+ }
913
+ /** Shorthand arg spec — `{ field: "string" | "number" | "integer" | "boolean" }`. */
914
+ export interface AiActionDef {
915
+ name: string;
916
+ description?: string;
917
+ args?: Record<string, string>;
918
+ }
919
+ export interface AiHistoryMsg {
920
+ from: 'player' | 'npc' | 'event';
921
+ text?: string;
922
+ data?: unknown;
923
+ }
924
+ export interface AiActOptions {
925
+ model?: string;
926
+ maxTokens?: number;
927
+ temperature?: number;
928
+ }
929
+ export interface AiActParams {
930
+ persona?: AiPersona;
931
+ /** Arbitrary game-defined JSON of what the AI perceives this turn. */
932
+ observation?: unknown;
933
+ actions?: AiActionDef[];
934
+ history?: AiHistoryMsg[];
935
+ options?: AiActOptions;
936
+ }
937
+ /** A player-level intent the AI chose — the game must validate + execute it. */
938
+ export interface AiActionCall {
939
+ name: string;
940
+ args: unknown;
941
+ }
942
+ export interface AiUsage {
943
+ credits: number;
944
+ balanceCredits: number;
945
+ limitReached: boolean;
946
+ model: string;
947
+ }
948
+ export interface AiActSuccess {
949
+ ok: true;
950
+ /** Dialogue — display only, no state effect. */
951
+ say?: string;
952
+ /** Chosen player-level intents — the game validates + executes each. */
953
+ do?: AiActionCall[];
954
+ usage?: AiUsage;
955
+ }
956
+ export interface AiActFailure {
957
+ ok: false;
958
+ reason: AiReason;
959
+ }
960
+ export type AiActResult = AiActSuccess | AiActFailure;
905
961
  export interface RealtimeGetTokenParams {
906
962
  /** Opaque, forwarded to server-side auth (future use). */
907
963
  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.16",
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",