@umicat/phaser-sdk 1.0.14 → 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 +73 -0
- package/dist/ai/AiModule.d.ts +43 -0
- package/dist/ai/AiModule.js +87 -0
- package/dist/core/Transport.d.ts +3 -1
- package/dist/core/Umicat.d.ts +3 -0
- package/dist/core/Umicat.js +2 -0
- package/dist/core/transports/PostMessageTransport.d.ts +1 -1
- package/dist/core/transports/PostMessageTransport.js +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +57 -1
- package/dist/scene/SceneLoader.js +21 -2
- package/package.json +1 -1
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
|
+
}
|
package/dist/core/Transport.d.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface Transport {
|
|
|
19
19
|
* `umicat.rooms.*` is unavailable in that case.
|
|
20
20
|
*/
|
|
21
21
|
readonly realtimeUrl?: string;
|
|
22
|
-
|
|
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 {
|
package/dist/core/Umicat.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/Umicat.js
CHANGED
|
@@ -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
|
-
},
|
|
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)
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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;
|
|
@@ -109,6 +109,20 @@ export function preloadSceneAssets(scene, sceneFile, manifest) {
|
|
|
109
109
|
queueAssetLoad(scene, asset);
|
|
110
110
|
state.requestedAssetIds.add(id);
|
|
111
111
|
}
|
|
112
|
+
// Audio assets are GLOBAL — BGM / SFX are played from code (`sound.play(key)`),
|
|
113
|
+
// not attached to a scene entity — so the entity walk above never collects
|
|
114
|
+
// them and they'd never load. Eagerly load every audio asset in the manifest
|
|
115
|
+
// so `cache.audio.has(key)` is true and the game can play it with no manual
|
|
116
|
+
// BootScene preload. (Without this, registering BGM in the manifest silently
|
|
117
|
+
// produced no sound — the asset existed but was never queued.)
|
|
118
|
+
for (const asset of manifest.assets ?? []) {
|
|
119
|
+
if (asset.kind !== 'audio')
|
|
120
|
+
continue;
|
|
121
|
+
if (state.requestedAssetIds.has(asset.id))
|
|
122
|
+
continue;
|
|
123
|
+
queueAssetLoad(scene, asset);
|
|
124
|
+
state.requestedAssetIds.add(asset.id);
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
function collectAssetIds(entities, manifest, resolveTilemapFile) {
|
|
114
128
|
const ids = new Set();
|
|
@@ -345,10 +359,15 @@ function queueAssetLoad(scene, asset) {
|
|
|
345
359
|
}
|
|
346
360
|
return;
|
|
347
361
|
case 'audio':
|
|
348
|
-
|
|
362
|
+
// Fall back to `id` when `textureKey` is absent. Audio (and json) entries
|
|
363
|
+
// are naturally written as `{ id, path, kind:'audio' }` — "textureKey" is
|
|
364
|
+
// a misnomer for a sound — so an agent/human routinely omits it. Without
|
|
365
|
+
// the fallback the clip loads under key `undefined` and `sound.play(id)`
|
|
366
|
+
// silently no-ops (the "generated BGM never plays" bug).
|
|
367
|
+
scene.load.audio(asset.textureKey || asset.id, asset.path);
|
|
349
368
|
return;
|
|
350
369
|
case 'json':
|
|
351
|
-
scene.load.json(asset.textureKey, asset.path);
|
|
370
|
+
scene.load.json(asset.textureKey || asset.id, asset.path);
|
|
352
371
|
return;
|
|
353
372
|
default: {
|
|
354
373
|
const exhaustive = asset.kind;
|