@alexkroman1/aai-cli 5.13.2 → 6.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.
Files changed (236) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +32 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +21 -30
  53. package/dist/templates/dispatch-center/agent.ts +4 -31
  54. package/dist/templates/dispatch-center/client.tsx +41 -28
  55. package/dist/templates/dispatch-center/shared.ts +73 -66
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +13 -15
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +42 -45
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +60 -62
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +10 -6
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +44 -46
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +40 -42
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +3 -6
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +21 -24
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +53 -55
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +3 -5
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +40 -43
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +32 -28
  82. package/dist/templates/infocom-adventure/shared.ts +36 -16
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +59 -58
  102. package/dist/templates/pizza-ordering/agent.ts +10 -159
  103. package/dist/templates/pizza-ordering/client.tsx +4 -4
  104. package/dist/templates/pizza-ordering/shared.ts +44 -27
  105. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  106. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  108. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  109. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  110. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  111. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  112. package/dist/templates/plan-and-execute/agent.ts +23 -0
  113. package/dist/templates/plan-and-execute/client.tsx +115 -0
  114. package/dist/templates/plan-and-execute/graph.ts +178 -0
  115. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  116. package/dist/templates/plan-and-execute/shared.ts +161 -0
  117. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  118. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  119. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  120. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  121. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  122. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  123. package/dist/templates/recap-workflow/agent.ts +95 -0
  124. package/dist/templates/recap-workflow/shared.ts +43 -0
  125. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  126. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  127. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  128. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  129. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  130. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  131. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  132. package/dist/templates/redline/agent.test.ts +222 -0
  133. package/dist/templates/redline/agent.ts +89 -0
  134. package/dist/templates/redline/client.tsx +184 -0
  135. package/dist/templates/redline/workflows/prompts.ts +68 -0
  136. package/dist/templates/redline/workflows/redline.ts +258 -0
  137. package/dist/templates/research-workflow/agent.test.ts +525 -0
  138. package/dist/templates/research-workflow/agent.ts +94 -0
  139. package/dist/templates/research-workflow/shared.ts +35 -0
  140. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  141. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  142. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  143. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  144. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  145. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  146. package/dist/templates/retail/agent.test.ts +319 -256
  147. package/dist/templates/retail/agent.ts +6 -46
  148. package/dist/templates/retail/authenticate.ts +2 -2
  149. package/dist/templates/retail/client.tsx +45 -24
  150. package/dist/templates/retail/registry.test.ts +40 -25
  151. package/dist/templates/retail/resolve.test.ts +17 -16
  152. package/dist/templates/retail/resolve.ts +32 -67
  153. package/dist/templates/retail/shared.test.ts +21 -19
  154. package/dist/templates/retail/shared.ts +61 -23
  155. package/dist/templates/retail/store.test.ts +47 -52
  156. package/dist/templates/retail/store.ts +92 -79
  157. package/dist/templates/retail/swap.test.ts +20 -17
  158. package/dist/templates/retail/swap.ts +7 -14
  159. package/dist/templates/retail/tools/cancel_pending_order.ts +7 -11
  160. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +26 -14
  161. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  162. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  163. package/dist/templates/retail/tools/get_item_details.ts +7 -8
  164. package/dist/templates/retail/tools/get_order_details.ts +7 -8
  165. package/dist/templates/retail/tools/get_product_details.ts +6 -8
  166. package/dist/templates/retail/tools/get_user_details.ts +6 -8
  167. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  168. package/dist/templates/retail/tools/modify_pending_order_address.ts +6 -10
  169. package/dist/templates/retail/tools/modify_pending_order_items.ts +8 -20
  170. package/dist/templates/retail/tools/modify_pending_order_payment.ts +7 -13
  171. package/dist/templates/retail/tools/modify_user_address.ts +6 -10
  172. package/dist/templates/retail/tools/return_delivered_order_items.ts +7 -13
  173. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  174. package/dist/templates/solo-rpg/agent.test.ts +169 -54
  175. package/dist/templates/solo-rpg/agent.ts +4 -22
  176. package/dist/templates/solo-rpg/client.tsx +19 -6
  177. package/dist/templates/solo-rpg/shared.ts +20 -20
  178. package/dist/templates/solo-rpg/tools/action_roll.ts +3 -5
  179. package/dist/templates/solo-rpg/tools/burn_momentum.ts +3 -5
  180. package/dist/templates/solo-rpg/tools/check_state.ts +7 -7
  181. package/dist/templates/solo-rpg/tools/load_game.ts +3 -3
  182. package/dist/templates/solo-rpg/tools/oracle.ts +19 -12
  183. package/dist/templates/solo-rpg/tools/save_game.ts +11 -8
  184. package/dist/templates/solo-rpg/tools/setup_character.ts +3 -3
  185. package/dist/templates/solo-rpg/tools/update_state.ts +13 -13
  186. package/dist/templates/support-line/agent.test.ts +343 -0
  187. package/dist/templates/support-line/agent.ts +27 -0
  188. package/dist/templates/support-line/client.tsx +122 -0
  189. package/dist/templates/support-line/graph.ts +224 -0
  190. package/dist/templates/support-line/knowledge.json +65 -0
  191. package/dist/templates/support-line/prompts.ts +95 -0
  192. package/dist/templates/support-line/shared.ts +240 -0
  193. package/dist/templates/support-line/system-prompt.md +17 -0
  194. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  195. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  196. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  197. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  198. package/dist/templates/transcription-workflow/agent.ts +92 -0
  199. package/dist/templates/transcription-workflow/client.tsx +234 -0
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  202. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  203. package/dist/templates/travel-concierge/agent.ts +29 -0
  204. package/dist/templates/travel-concierge/client.tsx +161 -0
  205. package/dist/templates/travel-concierge/routing.ts +138 -0
  206. package/dist/templates/travel-concierge/shared.ts +547 -0
  207. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  208. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  209. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  210. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  211. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  213. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  214. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  215. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  216. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  217. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  218. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  219. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  220. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  221. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  222. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  223. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  224. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  225. package/dist/templates/web-researcher/agent.ts +0 -2
  226. package/dist/templates/web-researcher/system-prompt.md +10 -0
  227. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  228. package/dist/typecheck.mjs +17 -15
  229. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  230. package/dist/worker-bundler.d.ts +14 -0
  231. package/dist/worker-bundler.mjs +1 -108
  232. package/dist/workflow-B-O97AqI.mjs +125 -0
  233. package/dist/workflow-bundler.d.ts +133 -0
  234. package/dist/workflow.d.ts +67 -0
  235. package/package.json +13 -9
  236. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -1,23 +1,24 @@
1
1
  import type { Db, ToolContext } from "@alexkroman1/aai";
2
+ import { createToolContext } from "@alexkroman1/aai/testing";
2
3
  import { describe, expect, test, vi } from "vitest";
3
4
  import {
4
5
  applyConsequences,
5
6
  DEFAULT_STATE,
6
7
  type GameState,
7
- getGameState,
8
+ gameSlot,
8
9
  MAX_NPCS,
9
10
  MIN_MOMENTUM,
10
11
  makeNpc,
11
12
  rollAction,
12
- saveGameState,
13
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";
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 oracle from "./tools/oracle.ts";
19
+ import saveGame from "./tools/save_game.ts";
20
+ import setupCharacter from "./tools/setup_character.ts";
21
+ import updateState from "./tools/update_state.ts";
21
22
 
22
23
  // ── Test doubles ─────────────────────────────────────────────────────────────
23
24
 
@@ -46,19 +47,11 @@ function makeDb(): { db: Db; rows: Map<string, unknown> } {
46
47
  return { db, rows };
47
48
  }
48
49
 
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
- // Never aborts — a test has no turn to cancel. `ctx.signal` is always
58
- // present at runtime, so a stub supplies one rather than omitting it.
59
- signal: new AbortController().signal,
60
- send: vi.fn(),
61
- };
50
+ /** `send` is a spy rather than the recorder `createToolContext` installs,
51
+ * because this suite asserts call counts on it. Each call gets its own slot
52
+ * store, which is what makes two contexts two games. */
53
+ function makeCtx(db: Db = makeDb().db): ToolContext {
54
+ return createToolContext({ db, send: vi.fn() });
62
55
  }
63
56
 
64
57
  const SETUP_ARGS = {
@@ -106,19 +99,19 @@ describe("setup_character", () => {
106
99
  await setupCharacter.execute(SETUP_ARGS, ctx);
107
100
 
108
101
  // Simulate a played, damaged game between setups.
109
- const played = getGameState(ctx);
110
- played.health = 1;
111
- played.momentum = -4;
112
- played.chaosFactor = 8;
113
- played.sceneCount = 42;
114
- saveGameState(ctx, played);
102
+ gameSlot.update(ctx, (played) => {
103
+ played.health = 1;
104
+ played.momentum = -4;
105
+ played.chaosFactor = 8;
106
+ played.sceneCount = 42;
107
+ });
115
108
 
116
109
  const result = (await setupCharacter.execute(
117
110
  { ...SETUP_ARGS, playerName: "Luna" },
118
111
  ctx,
119
112
  )) as Record<string, unknown>;
120
113
 
121
- const state = getGameState(ctx);
114
+ const state = gameSlot.get(ctx);
122
115
  expect(state.npcs).toHaveLength(1);
123
116
  expect(state.npcs[0]?.id).toBe("npc_1");
124
117
  expect(state.clocks).toHaveLength(1);
@@ -140,17 +133,21 @@ describe("setup_character", () => {
140
133
  test("stats are a permutation of [3,2,2,1,1] with the archetype's stat at 3", async () => {
141
134
  const ctx = makeCtx();
142
135
  await setupCharacter.execute(SETUP_ARGS, ctx);
143
- const state = getGameState(ctx);
136
+ const state = gameSlot.get(ctx);
144
137
  const stats = [state.edge, state.heart, state.iron, state.shadow, state.wits];
145
138
  expect([...stats].sort()).toEqual([1, 1, 2, 2, 3]);
146
139
  // investigator biases wits (index 4) to the high stat
147
140
  expect(state.wits).toBe(3);
148
141
  });
149
142
 
150
- test("game state is scoped per session — a second session sees a fresh game", async () => {
151
- await setupCharacter.execute(SETUP_ARGS, makeCtx("session-a"));
152
- // ctx.state is per-session by construction a new session, a new game.
153
- const other = (await checkState.execute({} as never, makeCtx("session-b"))) as {
143
+ test("a second, independent context sees a fresh game", async () => {
144
+ // What this really checks: the state lives in the SLOT and not in a
145
+ // module-level variable. `createToolContext()` hands each call its own
146
+ // detached slot store, so the isolation is per CONTEXT — two distinct
147
+ // session ids would prove nothing extra, and `sessionSlot` could stop
148
+ // keying by session with this still passing.
149
+ await setupCharacter.execute(SETUP_ARGS, makeCtx());
150
+ const other = (await checkState.execute({} as never, makeCtx())) as {
154
151
  initialized: boolean;
155
152
  };
156
153
  expect(other.initialized).toBe(false);
@@ -267,7 +264,7 @@ describe("burn_momentum", () => {
267
264
  clockTicks: 1,
268
265
  },
269
266
  };
270
- saveGameState(ctx, state);
267
+ gameSlot.set(ctx, state);
271
268
  }
272
269
 
273
270
  test("a legal burn reverts the miss's consequences, upgrades, and resets momentum", async () => {
@@ -278,7 +275,7 @@ describe("burn_momentum", () => {
278
275
  expect(result.burned).toBe(true);
279
276
  expect(result.newResultCode).toBe("STRONG_HIT");
280
277
 
281
- const state = getGameState(ctx);
278
+ const state = gameSlot.get(ctx);
282
279
  expect(state.health).toBe(5); // -2 reverted
283
280
  expect(state.clocks[0]?.filled).toBe(0); // tick reverted
284
281
  expect(state.momentum).toBe(2); // reset, overriding the strong hit's gain
@@ -306,21 +303,21 @@ describe("burn_momentum", () => {
306
303
 
307
304
  // Strong hits cannot be upgraded
308
305
  seedRolledState(8, ctx);
309
- const state = getGameState(ctx);
310
- state.lastRoll!.result = "STRONG_HIT";
311
- saveGameState(ctx, state);
306
+ gameSlot.update(ctx, (state) => {
307
+ state.lastRoll!.result = "STRONG_HIT";
308
+ });
312
309
  result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
313
310
  expect(result.error).toMatch(/already a Strong Hit/);
314
311
  });
315
312
 
316
313
  test("action_roll persists the roll so burn needs no dice arguments", async () => {
317
314
  const ctx = makeCtx();
318
- saveGameState(ctx, playingState());
315
+ gameSlot.set(ctx, playingState());
319
316
  await actionRoll.execute(
320
317
  { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "attack" },
321
318
  ctx,
322
319
  );
323
- const state = getGameState(ctx);
320
+ const state = gameSlot.get(ctx);
324
321
  expect(state.lastRoll).not.toBeNull();
325
322
  expect(state.lastRoll?.move).toBe("clash");
326
323
  expect(state.lastRoll?.deltas).toBeDefined();
@@ -367,18 +364,134 @@ describe("rollAction", () => {
367
364
  });
368
365
  });
369
366
 
367
+ // ── oracle ───────────────────────────────────────────────────────────────────
368
+ //
369
+ // `chaos_check` is the one oracle branch that WRITES: `checkChaosInterrupt`
370
+ // lowers the chaos factor when the roll lands. It used to read the slot with
371
+ // `gameSlot.get` and assign to what came back, under a comment claiming the
372
+ // value was live — which described the removed `ctx.state` bag, not a slot. The
373
+ // stored value is deep-frozen, so ~1 call in 5 at the default chaos factor (and
374
+ // ~6 in 10 as it climbs) threw a `TypeError` instead of answering. These
375
+ // assertions are on the STORED value for that reason: a body writing to a
376
+ // private copy would pass every check on its own return value.
377
+
378
+ describe("oracle", () => {
379
+ /** Force `d(sides)` to roll `value` on the next call. */
380
+ function mockRoll(value: number, sides: number) {
381
+ return vi.spyOn(Math, "random").mockReturnValue((value - 0.5) / sides);
382
+ }
383
+
384
+ test("a chaos interrupt that LANDS lowers the stored chaos factor", async () => {
385
+ const ctx = makeCtx();
386
+ const state = playingState();
387
+ state.chaosFactor = 9; // threshold 6 — a roll of 1 lands
388
+ gameSlot.set(ctx, state);
389
+ mockRoll(1, 10);
390
+
391
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
392
+ interrupted: boolean;
393
+ interruptType: string | null;
394
+ chaosFactor: number;
395
+ };
396
+
397
+ expect(result.interrupted).toBe(true);
398
+ expect(result.interruptType).toBeTruthy();
399
+ expect(result.chaosFactor).toBe(8);
400
+ // The half the old code could not do: the write reached the slot.
401
+ expect(gameSlot.get(ctx).chaosFactor).toBe(8);
402
+ });
403
+
404
+ test("the chaos factor floors at 3, where no roll is taken at all", async () => {
405
+ const ctx = makeCtx();
406
+ const state = playingState();
407
+ state.chaosFactor = 3; // threshold 0 — `checkChaosInterrupt` returns early
408
+ gameSlot.set(ctx, state);
409
+
410
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
411
+ interrupted: boolean;
412
+ chaosFactor: number;
413
+ };
414
+ expect(result.interrupted).toBe(false);
415
+ expect(gameSlot.get(ctx).chaosFactor).toBe(3);
416
+ });
417
+
418
+ test("a chaos check that MISSES changes nothing", async () => {
419
+ const ctx = makeCtx();
420
+ const state = playingState();
421
+ state.chaosFactor = 5; // threshold 2
422
+ gameSlot.set(ctx, state);
423
+ mockRoll(10, 10); // past the threshold
424
+
425
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
426
+ interrupted: boolean;
427
+ chaosFactor: number;
428
+ };
429
+ expect(result.interrupted).toBe(false);
430
+ expect(result.chaosFactor).toBe(5);
431
+ expect(gameSlot.get(ctx).chaosFactor).toBe(5);
432
+ });
433
+
434
+ test("a chaos check on an untouched session starts from the default factor", async () => {
435
+ const ctx = makeCtx();
436
+ mockRoll(1, 10); // DEFAULT_STATE.chaosFactor is 5, so threshold 2 — lands
437
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as { chaosFactor: number };
438
+ expect(result.chaosFactor).toBe(DEFAULT_STATE.chaosFactor - 1);
439
+ expect(gameSlot.get(ctx).chaosFactor).toBe(DEFAULT_STATE.chaosFactor - 1);
440
+ });
441
+
442
+ test("yes_no maps the d6 onto its three answers", async () => {
443
+ const ctx = makeCtx();
444
+ for (const [roll, answer] of [
445
+ [1, "No"],
446
+ [2, "No"],
447
+ [3, "Yes, but with a complication"],
448
+ [4, "Yes, but with a complication"],
449
+ [5, "Yes"],
450
+ [6, "Yes"],
451
+ ] as const) {
452
+ mockRoll(roll, 6);
453
+ const result = (await oracle.execute({ type: "yes_no" }, ctx)) as {
454
+ roll: number;
455
+ answer: string;
456
+ };
457
+ expect.soft(result, `roll ${roll}`).toEqual({ type: "yes_no", roll, answer });
458
+ }
459
+ });
460
+
461
+ test("the four inspiration branches answer without touching the game", async () => {
462
+ const ctx = makeCtx();
463
+ gameSlot.set(ctx, playingState());
464
+ const before = structuredClone(gameSlot.get(ctx));
465
+
466
+ const reaction = (await oracle.execute({ type: "npc_reaction" }, ctx)) as { reaction: string };
467
+ const twist = (await oracle.execute({ type: "scene_twist" }, ctx)) as { twist: string };
468
+ const theme = (await oracle.execute({ type: "action_theme" }, ctx)) as {
469
+ action: string;
470
+ theme: string;
471
+ seed: string;
472
+ };
473
+
474
+ expect(reaction.reaction).toBeTruthy();
475
+ expect(twist.twist).toBeTruthy();
476
+ expect(theme.action).toBeTruthy();
477
+ expect(theme.theme).toBeTruthy();
478
+ expect(theme.seed.split(" ")).toHaveLength(3);
479
+ expect(gameSlot.get(ctx)).toEqual(before);
480
+ });
481
+ });
482
+
370
483
  // ── update_state: clocks, caps, validation ───────────────────────────────────
371
484
 
372
485
  describe("update_state", () => {
373
486
  test("clock ids never collide after a removal (max-scan, not length+1)", async () => {
374
487
  const ctx = makeCtx();
375
- saveGameState(ctx, playingState()); // has clock_1
488
+ gameSlot.set(ctx, playingState()); // has clock_1
376
489
 
377
490
  await updateState.execute({ addClockName: "Second" }, ctx); // clock_2
378
491
  await updateState.execute({ removeClockName: "Doom" }, ctx); // removes clock_1
379
492
  await updateState.execute({ addClockName: "Third" }, ctx);
380
493
 
381
- const state = getGameState(ctx);
494
+ const state = gameSlot.get(ctx);
382
495
  const ids = state.clocks.map((c) => c.id);
383
496
  expect(new Set(ids).size).toBe(ids.length);
384
497
  expect(ids).toEqual(["clock_2", "clock_3"]);
@@ -388,7 +501,7 @@ describe("update_state", () => {
388
501
  const ctx = makeCtx();
389
502
  const state = playingState();
390
503
  state.clocks[0]!.filled = 3; // 3 of 4
391
- saveGameState(ctx, state);
504
+ gameSlot.set(ctx, state);
392
505
 
393
506
  const result = (await updateState.execute({ advanceClockName: "Doom" }, ctx)) as {
394
507
  clockEvents: { clock: string; trigger: string }[];
@@ -402,13 +515,13 @@ describe("update_state", () => {
402
515
  while (state.npcs.length < MAX_NPCS) {
403
516
  state.npcs.push(makeNpc({ id: `npc_${state.npcs.length + 1}`, name: "Extra" }));
404
517
  }
405
- saveGameState(ctx, state);
518
+ gameSlot.set(ctx, state);
406
519
 
407
520
  const result = (await updateState.execute({ addNpcName: "One Too Many" }, ctx)) as {
408
521
  warnings?: string[];
409
522
  };
410
523
  expect(result.warnings?.[0]).toMatch(/NPC limit/);
411
- const after = getGameState(ctx);
524
+ const after = gameSlot.get(ctx);
412
525
  expect(after.npcs).toHaveLength(MAX_NPCS);
413
526
  });
414
527
 
@@ -438,11 +551,11 @@ describe("save_game / load_game", () => {
438
551
  const { db, rows } = makeDb();
439
552
 
440
553
  // Session A plays and saves.
441
- const sessionA = makeCtx("session-a", db);
554
+ const sessionA = makeCtx(db);
442
555
  const played = playingState();
443
556
  played.playerName = "Kael";
444
557
  played.sceneCount = 7;
445
- saveGameState(sessionA, played);
558
+ gameSlot.set(sessionA, played);
446
559
  const saved = (await saveGame.execute({ slot: "chapter-2" }, sessionA)) as Record<
447
560
  string,
448
561
  unknown
@@ -451,8 +564,8 @@ describe("save_game / load_game", () => {
451
564
  expect(saved.slot).toBe("chapter-2");
452
565
  expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
453
566
 
454
- // Session B (fresh ctx.state, same app db) resumes it.
455
- const sessionB = makeCtx("session-b", db);
567
+ // Session B (a fresh game slot, the same app db) resumes it.
568
+ const sessionB = makeCtx(db);
456
569
  const loaded = (await loadGame.execute({ slot: "chapter-2" }, sessionB)) as Record<
457
570
  string,
458
571
  unknown
@@ -460,7 +573,7 @@ describe("save_game / load_game", () => {
460
573
  expect(loaded.loaded).toBe(true);
461
574
  expect(loaded.playerName).toBe("Kael");
462
575
  expect(loaded.sceneCount).toBe(7);
463
- expect(getGameState(sessionB).playerName).toBe("Kael");
576
+ expect(gameSlot.get(sessionB).playerName).toBe("Kael");
464
577
  });
465
578
 
466
579
  test("loading a missing slot reports an error instead of resetting the game", async () => {
@@ -471,10 +584,12 @@ describe("save_game / load_game", () => {
471
584
 
472
585
  test("saving twice to one slot upserts — the newer save wins", async () => {
473
586
  const { db, rows } = makeDb();
474
- const ctx = makeCtx("session-a", db);
475
- saveGameState(ctx, playingState());
587
+ const ctx = makeCtx(db);
588
+ gameSlot.set(ctx, playingState());
476
589
  await saveGame.execute({}, ctx); // autosave
477
- getGameState(ctx).sceneCount = 9;
590
+ gameSlot.update(ctx, (game) => {
591
+ game.sceneCount = 9;
592
+ });
478
593
  await saveGame.execute({}, ctx);
479
594
  expect(rows.size).toBe(1);
480
595
  expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
@@ -1,18 +1,8 @@
1
1
  import { agent } 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";
2
+ import { gameSlot } from "./shared.ts";
12
3
 
13
4
  export default agent({
14
5
  name: "Solo RPG",
15
- systemPrompt,
16
6
  greeting:
17
7
  "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.",
18
8
  sttPrompt:
@@ -22,15 +12,7 @@ export default agent({
22
12
  // One declaration replaces a `ctx.send("game_state", state)` in every
23
13
  // state-mutating tool — six of them, and adding a seventh meant
24
14
  // remembering to push or watching the UI quietly fall out of sync.
25
- syncState: (s: StateSlot) => s.game ?? DEFAULT_STATE,
26
- tools: {
27
- action_roll: actionRoll,
28
- burn_momentum: burnMomentum,
29
- check_state: checkState,
30
- load_game: loadGame,
31
- oracle,
32
- save_game: saveGame,
33
- setup_character: setupCharacter,
34
- update_state: updateState,
35
- },
15
+ // The identity projection: this campaign IS what the client renders, and the
16
+ // slot's own default is what a session that has run no tool projects.
17
+ syncState: gameSlot.projection((game) => game),
36
18
  });
@@ -1,12 +1,13 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
+ import type { DeepReadonly } from "@alexkroman1/aai";
2
3
  import { ChatView, client, SidebarLayout, StartScreen, useAgentState } from "@alexkroman1/aai-ui";
3
4
  import type { ReactNode } from "react";
4
5
  import {
5
6
  type Clock,
6
- DEFAULT_STATE,
7
7
  type Disposition,
8
8
  type GameState,
9
9
  GENRES,
10
+ gameSlot,
10
11
  MAX_RESOURCE,
11
12
  MIN_MOMENTUM,
12
13
  type NPC,
@@ -14,6 +15,18 @@ import {
14
15
  type TIME_PHASES,
15
16
  } from "./shared.ts";
16
17
 
18
+ // The campaign before the first tool call, derived from the SAME projection the
19
+ // server pushes rather than from the module default beside it — so a session
20
+ // that has run no tool renders exactly what its first push will replace. Every
21
+ // sibling template derives its fallback this way; this one read `DEFAULT_STATE`
22
+ // directly, which is a second copy of "what an untouched session looks like".
23
+ //
24
+ // It is typed READONLY because that is what the projection produces, and a
25
+ // client only ever renders what the server pushed — so the four components
26
+ // below take a readonly campaign too, which is the deep-readonly slot
27
+ // propagating exactly as far as it should.
28
+ const EMPTY_GAME = gameSlot.projection((game) => game)(undefined);
29
+
17
30
  // ── Color Palette ────────────────────────────────────────────────────────────
18
31
  const C = {
19
32
  bg: "#0a0a0c",
@@ -290,7 +303,7 @@ function StatPip({ label, value }: { label: string; value: number }) {
290
303
  );
291
304
  }
292
305
 
293
- function ClockDisplay({ clock }: { clock: Clock }) {
306
+ function ClockDisplay({ clock }: { clock: DeepReadonly<Clock> }) {
294
307
  const typeColor =
295
308
  clock.clockType === "threat"
296
309
  ? C.threat
@@ -355,7 +368,7 @@ function ClockDisplay({ clock }: { clock: Clock }) {
355
368
  );
356
369
  }
357
370
 
358
- function NpcCard({ npc }: { npc: NPC }) {
371
+ function NpcCard({ npc }: { npc: DeepReadonly<NPC> }) {
359
372
  const dispColor = C.disposition[npc.disposition] || C.textMuted;
360
373
  const icon = DISP_ICON[npc.disposition] || "\u25CB";
361
374
  return (
@@ -390,7 +403,7 @@ function NpcCard({ npc }: { npc: NPC }) {
390
403
  );
391
404
  }
392
405
 
393
- function StoryArc({ story }: { story: StoryBlueprint }) {
406
+ function StoryArc({ story }: { story: DeepReadonly<StoryBlueprint> }) {
394
407
  const totalActs = story.acts.length;
395
408
  const currentPhase = story.acts[story.currentAct - 1]?.phase ?? "";
396
409
  const pct = totalActs > 0 ? ((story.currentAct - 1) / totalActs) * 100 : 0;
@@ -443,7 +456,7 @@ function StoryArc({ story }: { story: StoryBlueprint }) {
443
456
 
444
457
  // ── Sidebar ──────────────────────────────────────────────────────────────────
445
458
 
446
- function Sidebar({ game }: { game: GameState }) {
459
+ function Sidebar({ game }: { game: DeepReadonly<GameState> }) {
447
460
  return (
448
461
  <div
449
462
  style={{
@@ -771,7 +784,7 @@ function SoloRPGApp() {
771
784
  // The agent's own session state, projected by `syncState` and pushed after
772
785
  // every tool call — no per-tool `ctx.send`, and nothing to keep in step
773
786
  // when a new tool starts mutating the game.
774
- const game = useAgentState<GameState>() ?? DEFAULT_STATE;
787
+ const game = useAgentState<DeepReadonly<GameState>>(EMPTY_GAME);
775
788
 
776
789
  return (
777
790
  <StartScreen
@@ -1,4 +1,4 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { type DeepReadonly, sessionSlot, type ToolContext } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
 
4
4
  // ── Tuning Constants ─────────────────────────────────────────────────────────
@@ -320,24 +320,24 @@ export const DEFAULT_STATE: GameState = {
320
320
  kidMode: false,
321
321
  };
322
322
 
323
- // ── Live game state (ctx.state) ─────────────────────────────────────────────
324
- // The in-play game lives in `ctx.state`, the agent's per-session mutable
325
- // state — concurrent players get independent games by construction, and the
326
- // live game needs no persistence of its own (that's what save slots are for).
327
- export type StateSlot = { game?: GameState };
323
+ // ── Live game state (one session slot) ───────────────────────────────────────
324
+ // The in-play game lives in one `sessionSlot`, keyed per session — concurrent
325
+ // players get independent games by construction. The clone is load-bearing:
326
+ // `DEFAULT_STATE` is one module-level object shared by every session in the
327
+ // process, so a factory without it would let one player's game show up in
328
+ // another's.
329
+ export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_STATE));
328
330
 
329
- /** The session's live game. Mutations to the returned object stick — it is
330
- * the object stored in `ctx.state`. */
331
- export function getGameState(ctx: ToolContext): GameState {
332
- const slot = ctx.state as StateSlot;
333
- slot.game ??= structuredClone(DEFAULT_STATE);
334
- return slot.game;
335
- }
336
-
337
- /** Replace the session's live game wholesale (setup, load). */
338
- export function saveGameState(ctx: ToolContext, state: GameState): void {
339
- (ctx.state as StateSlot).game = state;
340
- }
331
+ /**
332
+ * The game as a READ hands it out: deep-frozen, and typed to say so.
333
+ *
334
+ * Every pure helper below takes this rather than {@link GameState}, which is
335
+ * the widening a deep-readonly slot forces and the reason it is worth doing:
336
+ * a mutable `GameState` still satisfies it, so a helper called with an
337
+ * `updateTool` draft is unaffected, while a helper that WOULD have mutated
338
+ * stops compiling instead of throwing at its first call in production.
339
+ */
340
+ export type FrozenGameState = DeepReadonly<GameState>;
341
341
 
342
342
  // ── Persistent save slots (ctx.db) ───────────────────────────────────────────
343
343
  // save_game / load_game are genuine cross-session persistence, so they use
@@ -441,7 +441,7 @@ export function makeNpc(opts: {
441
441
  };
442
442
  }
443
443
 
444
- export function clockSummary(c: Clock) {
444
+ export function clockSummary(c: DeepReadonly<Clock>) {
445
445
  return {
446
446
  id: c.id,
447
447
  name: c.name,
@@ -458,7 +458,7 @@ export function clockSummary(c: Clock) {
458
458
  * setup_character, update_state, and check_state. Includes the player's
459
459
  * content boundaries (contentLines) so they survive past the setup turn.
460
460
  */
461
- export function stateSummary(state: GameState) {
461
+ export function stateSummary(state: FrozenGameState) {
462
462
  return {
463
463
  initialized: state.initialized,
464
464
  phase: state.phase,
@@ -1,10 +1,9 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
2
  import {
4
3
  applyConsequences,
5
4
  canBurnMomentum,
6
5
  checkChaosInterrupt,
7
- getGameState,
6
+ gameSlot,
8
7
  MOVE_LABELS,
9
8
  MOVES,
10
9
  RESULT_LABELS,
@@ -12,7 +11,7 @@ import {
12
11
  updateChaosFactor,
13
12
  } from "../shared.ts";
14
13
 
15
- export const actionRoll = tool({
14
+ export default gameSlot.updateTool({
16
15
  description:
17
16
  "Core mechanic. Roll 2d6 + stat (capped at 10) vs 2d10 challenge dice. Also applies consequences (health/spirit/supply/momentum changes, clock advancement) based on move type, position, and result. Call for ANY risky action. Pure conversation needs no roll.",
18
17
  inputSchema: z.object({
@@ -25,8 +24,7 @@ export const actionRoll = tool({
25
24
  purpose: z.string().max(300).describe("What the character is attempting"),
26
25
  targetNpcId: z.string().max(32).describe("Target NPC id for social moves").optional(),
27
26
  }),
28
- async execute(args, ctx) {
29
- const state = getGameState(ctx);
27
+ execute(args, state) {
30
28
  const statValue = state[args.stat];
31
29
  const roll = rollAction(args.stat, statValue, args.move);
32
30
 
@@ -1,18 +1,16 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import {
3
2
  applyConsequences,
4
3
  canBurnMomentum,
5
- getGameState,
4
+ gameSlot,
6
5
  MOMENTUM_RESET,
7
6
  RESULT_LABELS,
8
7
  revertConsequences,
9
8
  } from "../shared.ts";
10
9
 
11
- export const burnMomentum = tool({
10
+ export default gameSlot.updateTool({
12
11
  description:
13
12
  "Burn momentum to upgrade the most recent action roll. Only valid when current momentum beats the roll's challenge dice (both dice for a full upgrade, one for Miss to Weak Hit). Reverts the original result's consequences, applies the upgraded result, and resets momentum to +2.",
14
- async execute(_args, ctx) {
15
- const state = getGameState(ctx);
13
+ execute(_args, state) {
16
14
  const last = state.lastRoll;
17
15
  if (!last) return { error: "No recent action roll to upgrade. Roll first." };
18
16
  if (last.result === "STRONG_HIT") {
@@ -1,11 +1,11 @@
1
- import { tool } from "@alexkroman1/aai";
2
- import { getGameState, stateSummary } from "../shared.ts";
1
+ import { gameSlot, stateSummary } from "../shared.ts";
3
2
 
4
- export const checkState = tool({
3
+ // `gameSlot.tool`, the READING half: the body is handed the value, so it needs
4
+ // neither a context annotation nor an opening `gameSlot.get(ctx)` — and what it
5
+ // is handed is deep-frozen, which is what makes "this tool only reads" a
6
+ // property of the declaration rather than a claim in a comment.
7
+ export default gameSlot.tool({
5
8
  description:
6
9
  "Returns the full current game state. Call this at the start of every turn, before narrating or rolling, and treat the returned values as ground truth — never guess or remember stats from previous turns.",
7
- async execute(_args, ctx) {
8
- const state = getGameState(ctx);
9
- return stateSummary(state);
10
- },
10
+ execute: (_args, game) => stateSummary(game),
11
11
  });
@@ -1,17 +1,17 @@
1
1
  import { tool } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
  import type { GameState } from "../shared.ts";
4
- import { loadState, saveGameState, saveSlotKey, saveSlotParam } from "../shared.ts";
4
+ import { gameSlot, loadState, saveSlotKey, saveSlotParam } from "../shared.ts";
5
5
 
6
6
  // Requires storage — `aai storage enable` (or DATABASE_URL in .env under
7
7
  // `aai dev`); the rest of the game works without it.
8
- export const loadGame = tool({
8
+ export default tool({
9
9
  description: "Load a previously saved game.",
10
10
  inputSchema: z.object({ slot: saveSlotParam }),
11
11
  async execute(args, ctx) {
12
12
  const saved = await loadState<GameState>(ctx, saveSlotKey(args.slot));
13
13
  if (!saved) return { error: "No save found." };
14
- saveGameState(ctx, saved);
14
+ gameSlot.set(ctx, saved);
15
15
  return {
16
16
  loaded: true,
17
17
  playerName: saved.playerName,