@alexkroman1/aai-cli 5.1.1 → 5.2.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.
Files changed (99) hide show
  1. package/dist/_agent.d.ts +39 -0
  2. package/dist/_api-client.d.ts +37 -0
  3. package/dist/_bundler.d.ts +46 -0
  4. package/dist/_config.d.ts +57 -0
  5. package/dist/_default-html.d.ts +12 -0
  6. package/dist/_deploy.d.ts +28 -0
  7. package/dist/_dev-server.d.ts +74 -0
  8. package/dist/{_init-B-LEVTHx.mjs → _init-DU-sXH6S.mjs} +44 -65
  9. package/dist/_init.d.ts +7 -0
  10. package/dist/_mock-api.d.ts +22 -0
  11. package/dist/_mock-registry.d.ts +17 -0
  12. package/dist/_output.d.ts +50 -0
  13. package/dist/_server-common.d.ts +17 -0
  14. package/dist/_slug-api.d.ts +15 -0
  15. package/dist/_templates.d.ts +21 -0
  16. package/dist/_typecheck-gate.d.ts +7 -0
  17. package/dist/_ui.d.ts +22 -0
  18. package/dist/_utils.d.ts +48 -0
  19. package/dist/_vite-env.d.ts +8 -0
  20. package/dist/build.d.ts +16 -0
  21. package/dist/cli.d.ts +1 -0
  22. package/dist/cli.mjs +1 -1
  23. package/dist/client-bundler.d.ts +21 -0
  24. package/dist/delete.d.ts +18 -0
  25. package/dist/deploy.d.ts +15 -0
  26. package/dist/dev.d.ts +13 -0
  27. package/dist/{init-tc3Rbgrf.mjs → init-BjeK8crW.mjs} +1 -1
  28. package/dist/init.d.ts +24 -0
  29. package/dist/scaffold/.env.example +11 -0
  30. package/dist/scaffold/CLAUDE.md +919 -0
  31. package/dist/scaffold/global.d.ts +1 -0
  32. package/dist/scaffold/package.json +32 -0
  33. package/dist/scaffold/pnpm-workspace.yaml +13 -0
  34. package/dist/scaffold/tsconfig.json +19 -0
  35. package/dist/scaffold/vite.config.ts +14 -0
  36. package/dist/scaffold/vitest.config.ts +24 -0
  37. package/dist/secret.d.ts +25 -0
  38. package/dist/storage.d.ts +22 -0
  39. package/dist/templates/code-interpreter/agent.ts +11 -0
  40. package/dist/templates/code-interpreter/system-prompt.md +18 -0
  41. package/dist/templates/dispatch-center/agent.test.ts +153 -0
  42. package/dist/templates/dispatch-center/agent.ts +50 -0
  43. package/dist/templates/dispatch-center/client.tsx +378 -0
  44. package/dist/templates/dispatch-center/shared.ts +631 -0
  45. package/dist/templates/dispatch-center/system-prompt.md +39 -0
  46. package/dist/templates/dispatch-center/tools/incident_add_note.ts +27 -0
  47. package/dist/templates/dispatch-center/tools/incident_create.ts +79 -0
  48. package/dist/templates/dispatch-center/tools/incident_escalate.ts +88 -0
  49. package/dist/templates/dispatch-center/tools/incident_get.ts +36 -0
  50. package/dist/templates/dispatch-center/tools/incident_triage.ts +78 -0
  51. package/dist/templates/dispatch-center/tools/incident_update_status.ts +68 -0
  52. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +52 -0
  53. package/dist/templates/dispatch-center/tools/ops_protocols.ts +27 -0
  54. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +149 -0
  55. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +97 -0
  56. package/dist/templates/dispatch-center/tools/resources_get_available.ts +36 -0
  57. package/dist/templates/dispatch-center/tools/resources_update_status.ts +62 -0
  58. package/dist/templates/embedded-assets/agent.ts +55 -0
  59. package/dist/templates/embedded-assets/knowledge.json +20 -0
  60. package/dist/templates/health-assistant/agent.ts +174 -0
  61. package/dist/templates/health-assistant/system-prompt.md +17 -0
  62. package/dist/templates/infocom-adventure/agent.ts +119 -0
  63. package/dist/templates/infocom-adventure/client.tsx +279 -0
  64. package/dist/templates/infocom-adventure/shared.ts +38 -0
  65. package/dist/templates/infocom-adventure/system-prompt.md +43 -0
  66. package/dist/templates/math-buddy/agent.ts +16 -0
  67. package/dist/templates/math-buddy/system-prompt.md +12 -0
  68. package/dist/templates/night-owl/agent.ts +76 -0
  69. package/dist/templates/night-owl/client.tsx +130 -0
  70. package/dist/templates/night-owl/shared.ts +5 -0
  71. package/dist/templates/personal-finance/agent.ts +11 -0
  72. package/dist/templates/personal-finance/system-prompt.md +17 -0
  73. package/dist/templates/pipeline-simple/agent.test.ts +32 -0
  74. package/dist/templates/pipeline-simple/agent.ts +13 -0
  75. package/dist/templates/pizza-ordering/agent.test.ts +235 -0
  76. package/dist/templates/pizza-ordering/agent.ts +174 -0
  77. package/dist/templates/pizza-ordering/client.tsx +120 -0
  78. package/dist/templates/pizza-ordering/shared.ts +142 -0
  79. package/dist/templates/pizza-ordering/system-prompt.md +17 -0
  80. package/dist/templates/simple/agent.ts +5 -0
  81. package/dist/templates/solo-rpg/agent.test.ts +477 -0
  82. package/dist/templates/solo-rpg/agent.ts +37 -0
  83. package/dist/templates/solo-rpg/client.tsx +809 -0
  84. package/dist/templates/solo-rpg/shared.ts +747 -0
  85. package/dist/templates/solo-rpg/system-prompt.md +115 -0
  86. package/dist/templates/solo-rpg/tools/action_roll.ts +98 -0
  87. package/dist/templates/solo-rpg/tools/burn_momentum.ts +69 -0
  88. package/dist/templates/solo-rpg/tools/check_state.ts +11 -0
  89. package/dist/templates/solo-rpg/tools/load_game.ts +25 -0
  90. package/dist/templates/solo-rpg/tools/oracle.ts +248 -0
  91. package/dist/templates/solo-rpg/tools/save_game.ts +20 -0
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +208 -0
  93. package/dist/templates/solo-rpg/tools/update_state.ts +170 -0
  94. package/dist/templates/web-researcher/agent.ts +11 -0
  95. package/dist/test.d.ts +39 -0
  96. package/dist/tsdown.config.d.ts +2 -0
  97. package/dist/typecheck.d.ts +26 -0
  98. package/dist/worker-bundler.d.ts +29 -0
  99. package/package.json +9 -6
@@ -0,0 +1,17 @@
1
+ You are a friendly pizza order-taker at Pizza Palace. Keep responses short and conversational, optimized for voice.
2
+
3
+ Your job is to help customers build their pizza order step by step. Guide them through size, crust, and toppings.
4
+
5
+ The menu (sizes, crusts, toppings, and prices) is appended below this prompt — it is generated from the same price list the ordering tools charge from.
6
+
7
+ Behavior:
8
+ - When a customer wants a pizza, collect size, crust, and toppings, then use add_pizza to add it.
9
+ - If they just say something like "pepperoni pizza", assume medium, regular crust, and confirm before adding.
10
+ - Always confirm what you added after using add_pizza.
11
+ - Use view_order when the customer asks to review their order.
12
+ - Use update_pizza if they want to change an existing pizza.
13
+ - Use remove_pizza if they want to remove one.
14
+ - When they say they are done ordering, use place_order.
15
+ - Suggest popular combos if they seem unsure. For example, "Our most popular is a large pepperoni with extra cheese."
16
+ - Always mention the running total after changes.
17
+ - Be warm but efficient. No long monologues.
@@ -0,0 +1,5 @@
1
+ import { agent } from "@alexkroman1/aai";
2
+
3
+ export default agent({
4
+ name: "Simple Assistant",
5
+ });
@@ -0,0 +1,477 @@
1
+ import type { Db, ToolContext } from "@alexkroman1/aai";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import {
4
+ applyConsequences,
5
+ DEFAULT_STATE,
6
+ type GameState,
7
+ getGameState,
8
+ MAX_NPCS,
9
+ MIN_MOMENTUM,
10
+ makeNpc,
11
+ rollAction,
12
+ saveGameState,
13
+ } from "./shared.ts";
14
+ import { actionRoll } from "./tools/action_roll.ts";
15
+ import { burnMomentum } from "./tools/burn_momentum.ts";
16
+ import { checkState } from "./tools/check_state.ts";
17
+ import { loadGame } from "./tools/load_game.ts";
18
+ import { saveGame } from "./tools/save_game.ts";
19
+ import { setupCharacter } from "./tools/setup_character.ts";
20
+ import { updateState } from "./tools/update_state.ts";
21
+
22
+ // ── Test doubles ─────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Map-backed fake of the app database, implementing exactly the three SQL
26
+ * statements the shared save-slot helpers emit (create table / select /
27
+ * upsert). Values are stored parsed, the way a postgres driver returns jsonb.
28
+ */
29
+ function makeDb(): { db: Db; rows: Map<string, unknown> } {
30
+ const rows = new Map<string, unknown>();
31
+ const db: Db = {
32
+ async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
33
+ if (sql.startsWith("create table if not exists app_state")) return [];
34
+ if (sql.startsWith("select value from app_state")) {
35
+ const key = params[0] as string;
36
+ return rows.has(key) ? ([{ value: structuredClone(rows.get(key)) }] as T[]) : [];
37
+ }
38
+ if (sql.startsWith("insert into app_state")) {
39
+ const [key, json] = params as [string, string];
40
+ rows.set(key, JSON.parse(json)); // $2::jsonb — parsed like postgres would
41
+ return [];
42
+ }
43
+ throw new Error(`unexpected SQL in test: ${sql}`);
44
+ },
45
+ };
46
+ return { db, rows };
47
+ }
48
+
49
+ function makeCtx(sessionId = "session-a", db: Db = makeDb().db): ToolContext {
50
+ return {
51
+ env: {},
52
+ state: {},
53
+ db,
54
+ generate: () => Promise.reject(new Error("generate not available in tests")),
55
+ messages: [],
56
+ sessionId,
57
+ send: vi.fn(),
58
+ };
59
+ }
60
+
61
+ const SETUP_ARGS = {
62
+ genre: "dark_fantasy",
63
+ tone: "dark_gritty",
64
+ archetype: "investigator",
65
+ playerName: "Kael",
66
+ characterConcept: "A haunted detective",
67
+ settingDescription: "A city of fog and iron.",
68
+ startingLocation: "The Docks",
69
+ locationDesc: "Rotting piers under gaslight.",
70
+ timeOfDay: "night" as const,
71
+ openingSituation: "A body washes ashore bearing your family crest.",
72
+ npc1Name: "Mira",
73
+ npc1Desc: "A wary informant",
74
+ npc1Disposition: "distrustful" as const,
75
+ npc1Agenda: "Pay off her debts",
76
+ threatClockName: "The Syndicate Closes In",
77
+ threatClockDesc: "Assassins find the player",
78
+ };
79
+
80
+ function playingState(): GameState {
81
+ const state = structuredClone(DEFAULT_STATE);
82
+ state.initialized = true;
83
+ state.phase = "playing";
84
+ state.sceneCount = 3;
85
+ state.npcs.push(makeNpc({ id: "npc_1", name: "Mira", disposition: "neutral" }));
86
+ state.clocks.push({
87
+ id: "clock_1",
88
+ name: "Doom",
89
+ clockType: "threat",
90
+ segments: 4,
91
+ filled: 0,
92
+ triggerDescription: "The doom arrives",
93
+ });
94
+ return state;
95
+ }
96
+
97
+ // ── setup_character ──────────────────────────────────────────────────────────
98
+
99
+ describe("setup_character", () => {
100
+ test("running setup twice starts fresh: no duplicate ids, no stale resources, truthful return", async () => {
101
+ const ctx = makeCtx();
102
+
103
+ await setupCharacter.execute(SETUP_ARGS, ctx);
104
+
105
+ // Simulate a played, damaged game between setups.
106
+ const played = getGameState(ctx);
107
+ played.health = 1;
108
+ played.momentum = -4;
109
+ played.chaosFactor = 8;
110
+ played.sceneCount = 42;
111
+ saveGameState(ctx, played);
112
+
113
+ const result = (await setupCharacter.execute(
114
+ { ...SETUP_ARGS, playerName: "Luna" },
115
+ ctx,
116
+ )) as Record<string, unknown>;
117
+
118
+ const state = getGameState(ctx);
119
+ expect(state.npcs).toHaveLength(1);
120
+ expect(state.npcs[0]?.id).toBe("npc_1");
121
+ expect(state.clocks).toHaveLength(1);
122
+ expect(state.clocks[0]?.id).toBe("clock_1");
123
+ expect(state.playerName).toBe("Luna");
124
+ expect(state.health).toBe(5);
125
+ expect(state.momentum).toBe(2);
126
+ expect(state.chaosFactor).toBe(5);
127
+ expect(state.sceneCount).toBe(1);
128
+ expect(state.sessionLog).toHaveLength(0);
129
+
130
+ // The return value reports the REAL saved state, not hardcoded numbers.
131
+ expect(result.health).toBe(state.health);
132
+ expect(result.momentum).toBe(state.momentum);
133
+ expect(result.chaosFactor).toBe(state.chaosFactor);
134
+ expect(result.playerName).toBe("Luna");
135
+ });
136
+
137
+ test("stats are a permutation of [3,2,2,1,1] with the archetype's stat at 3", async () => {
138
+ const ctx = makeCtx();
139
+ await setupCharacter.execute(SETUP_ARGS, ctx);
140
+ const state = getGameState(ctx);
141
+ const stats = [state.edge, state.heart, state.iron, state.shadow, state.wits];
142
+ expect([...stats].sort()).toEqual([1, 1, 2, 2, 3]);
143
+ // investigator biases wits (index 4) to the high stat
144
+ expect(state.wits).toBe(3);
145
+ });
146
+
147
+ test("game state is scoped per session — a second session sees a fresh game", async () => {
148
+ await setupCharacter.execute(SETUP_ARGS, makeCtx("session-a"));
149
+ // ctx.state is per-session by construction — a new session, a new game.
150
+ const other = (await checkState.execute({} as never, makeCtx("session-b"))) as {
151
+ initialized: boolean;
152
+ };
153
+ expect(other.initialized).toBe(false);
154
+ });
155
+ });
156
+
157
+ // ── applyConsequences ────────────────────────────────────────────────────────
158
+
159
+ describe("applyConsequences MISS matrix", () => {
160
+ const miss = { result: "MISS" as const, move: "clash" };
161
+
162
+ test("combat miss scales damage by position and ticks the threat clock", () => {
163
+ for (const [position, dmg, ticks] of [
164
+ ["controlled", 1, 1],
165
+ ["risky", 2, 1],
166
+ ["desperate", 3, 2],
167
+ ] as const) {
168
+ const state = playingState();
169
+ const { deltas, clockEvents } = applyConsequences(state, miss, position, "standard", null);
170
+ expect(state.health).toBe(5 - dmg);
171
+ expect(deltas.health).toBe(-dmg);
172
+ expect(state.clocks[0]?.filled).toBe(ticks);
173
+ expect(deltas.clockTicks).toBe(ticks);
174
+ expect(clockEvents).toHaveLength(0); // 4-segment clock not full yet
175
+ }
176
+ });
177
+
178
+ test("momentum loss is floored at MIN_MOMENTUM and deltas record the actual change", () => {
179
+ const state = playingState();
180
+ state.momentum = -5;
181
+ const { deltas } = applyConsequences(state, miss, "desperate", "standard", null);
182
+ expect(state.momentum).toBe(MIN_MOMENTUM); // -5 - 3 clamps to -6
183
+ expect(deltas.momentum).toBe(-1);
184
+ });
185
+
186
+ test("social miss with a target drops bond (floored at 0) and spirit", () => {
187
+ const state = playingState();
188
+ state.npcs[0]!.bond = 0;
189
+ const { deltas } = applyConsequences(
190
+ state,
191
+ { result: "MISS", move: "compel" },
192
+ "risky",
193
+ "standard",
194
+ "npc_1",
195
+ );
196
+ expect(state.npcs[0]?.bond).toBe(0);
197
+ expect(deltas.bond).toBe(0); // already at floor — nothing actually applied
198
+ expect(state.spirit).toBe(4);
199
+ });
200
+
201
+ test("filling the threat clock emits its trigger event and crisis flags fire at 0 health", () => {
202
+ const state = playingState();
203
+ state.clocks[0]!.filled = 3; // one tick from full
204
+ state.health = 2;
205
+ const { clockEvents } = applyConsequences(state, miss, "risky", "standard", null);
206
+ expect(clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
207
+ expect(state.health).toBe(0);
208
+ expect(state.crisisMode).toBe(true);
209
+ expect(state.gameOver).toBe(false);
210
+ });
211
+
212
+ test("strong hit with great effect gains +3 momentum and shifts disposition on compel", () => {
213
+ const state = playingState();
214
+ const { deltas } = applyConsequences(
215
+ state,
216
+ { result: "STRONG_HIT", move: "compel" },
217
+ "risky",
218
+ "great",
219
+ "npc_1",
220
+ );
221
+ expect(state.momentum).toBe(5);
222
+ expect(deltas.momentum).toBe(3);
223
+ expect(state.npcs[0]?.disposition).toBe("friendly");
224
+ expect(deltas.dispositionFrom).toBe("neutral");
225
+ expect(state.npcs[0]?.bond).toBe(1);
226
+ });
227
+ });
228
+
229
+ // ── burn_momentum ────────────────────────────────────────────────────────────
230
+
231
+ describe("burn_momentum", () => {
232
+ function seedRolledState(momentum: number, ctx: ToolContext) {
233
+ const state = playingState();
234
+ // A MISS was applied: health -2, momentum -2, clock +1.
235
+ state.health = 3;
236
+ state.clocks[0]!.filled = 1;
237
+ state.momentum = momentum;
238
+ state.lastRoll = {
239
+ d1: 2,
240
+ d2: 2,
241
+ c1: 3,
242
+ c2: 5,
243
+ statName: "iron",
244
+ statValue: 2,
245
+ actionScore: 6,
246
+ result: "MISS",
247
+ move: "clash",
248
+ match: false,
249
+ position: "risky",
250
+ effect: "standard",
251
+ targetNpcId: null,
252
+ deltas: {
253
+ health: -2,
254
+ spirit: 0,
255
+ supply: 0,
256
+ momentum: -2,
257
+ npcId: null,
258
+ bond: 0,
259
+ dispositionFrom: null,
260
+ dispositionTo: null,
261
+ clockId: "clock_1",
262
+ clockTicks: 1,
263
+ },
264
+ };
265
+ saveGameState(ctx, state);
266
+ }
267
+
268
+ test("a legal burn reverts the miss's consequences, upgrades, and resets momentum", async () => {
269
+ const ctx = makeCtx();
270
+ seedRolledState(8, ctx); // 8 beats both dice (3, 5)
271
+
272
+ const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
273
+ expect(result.burned).toBe(true);
274
+ expect(result.newResultCode).toBe("STRONG_HIT");
275
+
276
+ const state = getGameState(ctx);
277
+ expect(state.health).toBe(5); // -2 reverted
278
+ expect(state.clocks[0]?.filled).toBe(0); // tick reverted
279
+ expect(state.momentum).toBe(2); // reset, overriding the strong hit's gain
280
+ expect(state.lastRoll).toBeNull();
281
+ });
282
+
283
+ test("momentum beating only one die upgrades a MISS to WEAK_HIT", async () => {
284
+ const ctx = makeCtx();
285
+ seedRolledState(4, ctx); // beats 3, not 5
286
+ const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
287
+ expect(result.newResultCode).toBe("WEAK_HIT");
288
+ });
289
+
290
+ test("burn is refused with no stored roll, insufficient momentum, or a strong hit", async () => {
291
+ const ctx = makeCtx();
292
+
293
+ // No roll yet
294
+ let result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
295
+ expect(result.error).toMatch(/No recent action roll/);
296
+
297
+ // Momentum too low to beat either die
298
+ seedRolledState(2, ctx);
299
+ result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
300
+ expect(result.error).toMatch(/not high enough/);
301
+
302
+ // Strong hits cannot be upgraded
303
+ seedRolledState(8, ctx);
304
+ const state = getGameState(ctx);
305
+ state.lastRoll!.result = "STRONG_HIT";
306
+ saveGameState(ctx, state);
307
+ result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
308
+ expect(result.error).toMatch(/already a Strong Hit/);
309
+ });
310
+
311
+ test("action_roll persists the roll so burn needs no dice arguments", async () => {
312
+ const ctx = makeCtx();
313
+ saveGameState(ctx, playingState());
314
+ await actionRoll.execute(
315
+ { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "attack" },
316
+ ctx,
317
+ );
318
+ const state = getGameState(ctx);
319
+ expect(state.lastRoll).not.toBeNull();
320
+ expect(state.lastRoll?.move).toBe("clash");
321
+ expect(state.lastRoll?.deltas).toBeDefined();
322
+ });
323
+ });
324
+
325
+ // ── rollAction dice boundaries ───────────────────────────────────────────────
326
+
327
+ describe("rollAction", () => {
328
+ // Math.random call order inside rollAction: d1, d2, c1, c2.
329
+ function mockDice(d1: number, d2: number, c1: number, c2: number) {
330
+ const spy = vi.spyOn(Math, "random");
331
+ for (const [value, sides] of [
332
+ [d1, 6],
333
+ [d2, 6],
334
+ [c1, 10],
335
+ [c2, 10],
336
+ ] as const) {
337
+ spy.mockReturnValueOnce((value - 0.5) / sides);
338
+ }
339
+ return spy;
340
+ }
341
+
342
+ test("tying a challenge die is NOT a beat — equal on both dice is a MISS with match", () => {
343
+ mockDice(3, 3, 8, 8); // action score 3+3+2 = 8 vs 8, 8
344
+ const roll = rollAction("wits", 2, "face_danger");
345
+ expect(roll.actionScore).toBe(8);
346
+ expect(roll.result).toBe("MISS");
347
+ expect(roll.match).toBe(true);
348
+ });
349
+
350
+ test("action score caps at 10 even when dice + stat exceed it", () => {
351
+ mockDice(6, 6, 1, 1); // 6+6+4 = 16 → capped to 10
352
+ const roll = rollAction("iron", 4, "clash");
353
+ expect(roll.actionScore).toBe(10);
354
+ expect(roll.result).toBe("STRONG_HIT");
355
+ });
356
+
357
+ test("beating exactly one die is a WEAK_HIT", () => {
358
+ mockDice(4, 2, 5, 9); // 4+2+2 = 8: beats 5, not 9
359
+ const roll = rollAction("edge", 2, "face_danger");
360
+ expect(roll.result).toBe("WEAK_HIT");
361
+ expect(roll.match).toBe(false);
362
+ });
363
+ });
364
+
365
+ // ── update_state: clocks, caps, validation ───────────────────────────────────
366
+
367
+ describe("update_state", () => {
368
+ test("clock ids never collide after a removal (max-scan, not length+1)", async () => {
369
+ const ctx = makeCtx();
370
+ saveGameState(ctx, playingState()); // has clock_1
371
+
372
+ await updateState.execute({ addClockName: "Second" }, ctx); // clock_2
373
+ await updateState.execute({ removeClockName: "Doom" }, ctx); // removes clock_1
374
+ await updateState.execute({ addClockName: "Third" }, ctx);
375
+
376
+ const state = getGameState(ctx);
377
+ const ids = state.clocks.map((c) => c.id);
378
+ expect(new Set(ids).size).toBe(ids.length);
379
+ expect(ids).toEqual(["clock_2", "clock_3"]);
380
+ });
381
+
382
+ test("advancing a clock to full reports its trigger event", async () => {
383
+ const ctx = makeCtx();
384
+ const state = playingState();
385
+ state.clocks[0]!.filled = 3; // 3 of 4
386
+ saveGameState(ctx, state);
387
+
388
+ const result = (await updateState.execute({ advanceClockName: "Doom" }, ctx)) as {
389
+ clockEvents: { clock: string; trigger: string }[];
390
+ };
391
+ expect(result.clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
392
+ });
393
+
394
+ test("NPC count is capped at MAX_NPCS with a warning", async () => {
395
+ const ctx = makeCtx();
396
+ const state = playingState();
397
+ while (state.npcs.length < MAX_NPCS) {
398
+ state.npcs.push(makeNpc({ id: `npc_${state.npcs.length + 1}`, name: "Extra" }));
399
+ }
400
+ saveGameState(ctx, state);
401
+
402
+ const result = (await updateState.execute({ addNpcName: "One Too Many" }, ctx)) as {
403
+ warnings?: string[];
404
+ };
405
+ expect(result.warnings?.[0]).toMatch(/NPC limit/);
406
+ const after = getGameState(ctx);
407
+ expect(after.npcs).toHaveLength(MAX_NPCS);
408
+ });
409
+
410
+ test("zod schemas reject out-of-range and malformed inputs", () => {
411
+ const params = updateState.parameters!;
412
+ expect(() => params.parse({ addClockSegments: 1 })).toThrow(); // below min
413
+ expect(() => params.parse({ addClockSegments: 13 })).toThrow(); // above max
414
+ expect(() => params.parse({ addClockSegments: 2.5 })).toThrow(); // non-integer
415
+ expect(() => params.parse({ updateNpcBond: 5 })).toThrow(); // above MAX_BOND
416
+ expect(() => params.parse({ updateNpcBond: -1 })).toThrow();
417
+ expect(() => params.parse({ timeOfDay: "noonish" })).toThrow(); // not a phase
418
+ expect(
419
+ params.parse({ addClockSegments: 6, updateNpcBond: 4, timeOfDay: "night" }),
420
+ ).toBeTruthy();
421
+
422
+ const slotParams = saveGame.parameters!;
423
+ expect(() => slotParams.parse({ slot: "../../etc" })).toThrow();
424
+ expect(() => slotParams.parse({ slot: "a".repeat(40) })).toThrow();
425
+ expect(slotParams.parse({ slot: "chapter-2" })).toBeTruthy();
426
+ });
427
+ });
428
+
429
+ // ── save_game / load_game: cross-session persistence via ctx.db ──────────────
430
+
431
+ describe("save_game / load_game", () => {
432
+ test("a save made in one session loads in a later session", async () => {
433
+ const { db, rows } = makeDb();
434
+
435
+ // Session A plays and saves.
436
+ const sessionA = makeCtx("session-a", db);
437
+ const played = playingState();
438
+ played.playerName = "Kael";
439
+ played.sceneCount = 7;
440
+ saveGameState(sessionA, played);
441
+ const saved = (await saveGame.execute({ slot: "chapter-2" }, sessionA)) as Record<
442
+ string,
443
+ unknown
444
+ >;
445
+ expect(saved.saved).toBe(true);
446
+ expect(saved.slot).toBe("chapter-2");
447
+ expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
448
+
449
+ // Session B (fresh ctx.state, same app db) resumes it.
450
+ const sessionB = makeCtx("session-b", db);
451
+ const loaded = (await loadGame.execute({ slot: "chapter-2" }, sessionB)) as Record<
452
+ string,
453
+ unknown
454
+ >;
455
+ expect(loaded.loaded).toBe(true);
456
+ expect(loaded.playerName).toBe("Kael");
457
+ expect(loaded.sceneCount).toBe(7);
458
+ expect(getGameState(sessionB).playerName).toBe("Kael");
459
+ });
460
+
461
+ test("loading a missing slot reports an error instead of resetting the game", async () => {
462
+ const ctx = makeCtx();
463
+ const result = (await loadGame.execute({ slot: "nope" }, ctx)) as { error?: string };
464
+ expect(result.error).toMatch(/No save found/);
465
+ });
466
+
467
+ test("saving twice to one slot upserts — the newer save wins", async () => {
468
+ const { db, rows } = makeDb();
469
+ const ctx = makeCtx("session-a", db);
470
+ saveGameState(ctx, playingState());
471
+ await saveGame.execute({}, ctx); // autosave
472
+ getGameState(ctx).sceneCount = 9;
473
+ await saveGame.execute({}, ctx);
474
+ expect(rows.size).toBe(1);
475
+ expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
476
+ });
477
+ });
@@ -0,0 +1,37 @@
1
+ import { agent, assemblyAIPipeline } from "@alexkroman1/aai";
2
+ import { DEFAULT_STATE, type StateSlot } from "./shared.ts";
3
+ import systemPrompt from "./system-prompt.md?raw";
4
+ import { actionRoll } from "./tools/action_roll.ts";
5
+ import { burnMomentum } from "./tools/burn_momentum.ts";
6
+ import { checkState } from "./tools/check_state.ts";
7
+ import { loadGame } from "./tools/load_game.ts";
8
+ import { oracle } from "./tools/oracle.ts";
9
+ import { saveGame } from "./tools/save_game.ts";
10
+ import { setupCharacter } from "./tools/setup_character.ts";
11
+ import { updateState } from "./tools/update_state.ts";
12
+
13
+ export default agent({
14
+ name: "Solo RPG",
15
+ ...assemblyAIPipeline(),
16
+ systemPrompt,
17
+ greeting:
18
+ "Welcome. Tell me your name, or describe the kind of story you want, and we will begin. You can say something like, dark fantasy warrior named Kael, or just give me a name and I will build a world around you.",
19
+ sttPrompt:
20
+ "Solo RPG terms: strong hit, weak hit, miss, momentum, chaos factor, clock, disposition, bond, edge, heart, iron, shadow, wits, face danger, compel, gather information, secure advantage, clash, strike, endure harm, endure stress, make connection, test bond, resupply, world shaping",
21
+ maxSteps: 8,
22
+
23
+ // One declaration replaces a `ctx.send("game_state", state)` in every
24
+ // state-mutating tool — six of them, and adding a seventh meant
25
+ // remembering to push or watching the UI quietly fall out of sync.
26
+ syncState: (s: StateSlot) => s.game ?? DEFAULT_STATE,
27
+ tools: {
28
+ action_roll: actionRoll,
29
+ burn_momentum: burnMomentum,
30
+ check_state: checkState,
31
+ load_game: loadGame,
32
+ oracle,
33
+ save_game: saveGame,
34
+ setup_character: setupCharacter,
35
+ update_state: updateState,
36
+ },
37
+ });