@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,747 @@
1
+ import type { ToolContext } from "@alexkroman1/aai";
2
+ import { z } from "zod";
3
+
4
+ // ── Tuning Constants ─────────────────────────────────────────────────────────
5
+ export const MAX_SESSION_LOG = 50;
6
+ export const MOMENTUM_RESET = 2;
7
+ export const MAX_RESOURCE = 5;
8
+ export const MIN_MOMENTUM = -6;
9
+ export const MAX_BOND = 4;
10
+ export const MAX_NPCS = 12;
11
+ export const MAX_CLOCKS = 8;
12
+ export const MIN_CLOCK_SEGMENTS = 2;
13
+ export const MAX_CLOCK_SEGMENTS = 12;
14
+ export const DEFAULT_CLOCK_SEGMENTS = 6;
15
+
16
+ // ── Creativity Seeds ─────────────────────────────────────────────────────────
17
+ const SEED_WORDS = [
18
+ "amber",
19
+ "coyote",
20
+ "furnace",
21
+ "silk",
22
+ "glacier",
23
+ "compass",
24
+ "terracotta",
25
+ "jasmine",
26
+ "anvil",
27
+ "cobalt",
28
+ "driftwood",
29
+ "saffron",
30
+ "limestone",
31
+ "falcon",
32
+ "obsidian",
33
+ "cedar",
34
+ "mercury",
35
+ "lantern",
36
+ "basalt",
37
+ "thistle",
38
+ "copper",
39
+ "monsoon",
40
+ "flint",
41
+ "orchid",
42
+ "pewter",
43
+ "canyon",
44
+ "quartz",
45
+ "ember",
46
+ "mahogany",
47
+ "coral",
48
+ ];
49
+
50
+ /** Unbiased Fisher-Yates shuffle. Returns a new array. */
51
+ export function shuffle<T>(arr: readonly T[]): T[] {
52
+ const out = [...arr];
53
+ for (let i = out.length - 1; i > 0; i--) {
54
+ const j = Math.floor(Math.random() * (i + 1));
55
+ [out[i], out[j]] = [out[j]!, out[i]!];
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export function creativitySeed(n = 3): string {
61
+ return shuffle(SEED_WORDS).slice(0, n).join(" ");
62
+ }
63
+
64
+ // ── Genres, Tones, Archetypes ────────────────────────────────────────────────
65
+ export const GENRES = {
66
+ dark_fantasy: "Dark Fantasy",
67
+ high_fantasy: "High Fantasy",
68
+ science_fiction: "Sci-Fi",
69
+ horror_mystery: "Horror / Mystery",
70
+ steampunk: "Steampunk",
71
+ cyberpunk: "Cyberpunk",
72
+ urban_fantasy: "Urban Fantasy",
73
+ victorian_crime: "Victorian Crime",
74
+ historical_roman: "Historical / Roman",
75
+ fairy_tale: "Fairy Tale World",
76
+ slice_of_life_90s: "Slice of Life 1990s",
77
+ outdoor_survival: "Outdoor Survival",
78
+ } as const;
79
+
80
+ export const TONES = {
81
+ dark_gritty: "Dark & Gritty",
82
+ serious_balanced: "Serious but Fair",
83
+ melancholic: "Melancholic",
84
+ absurd_grotesque: "Absurd & Grotesque",
85
+ slow_burn_horror: "Slow-Burn Horror",
86
+ cheerful_funny: "Cheerful & Fun",
87
+ romantic: "Romantic",
88
+ slapstick: "Slapstick",
89
+ epic_heroic: "Epic & Heroic",
90
+ tarantino: "Tarantino-Style",
91
+ cozy: "Cozy & Comfy",
92
+ tragicomic: "Tragicomic",
93
+ } as const;
94
+
95
+ export const ARCHETYPES = {
96
+ outsider_loner: "Outsider / Loner",
97
+ investigator: "Investigator / Curious",
98
+ trickster: "Trickster / Charmer",
99
+ protector: "Protector / Warrior",
100
+ hardboiled: "Hardboiled / Veteran",
101
+ scholar: "Scholar / Mystic",
102
+ healer: "Healer / Medic",
103
+ inventor: "Crafter / Inventor",
104
+ artist: "Artist / Bard",
105
+ } as const;
106
+
107
+ // ── Moves ────────────────────────────────────────────────────────────────────
108
+ // Pure conversation ("dialog") is deliberately NOT a rollable move — it has no
109
+ // risk, so the model narrates it without calling action_roll.
110
+ export const MOVES = [
111
+ "face_danger",
112
+ "compel",
113
+ "gather_information",
114
+ "secure_advantage",
115
+ "clash",
116
+ "strike",
117
+ "endure_harm",
118
+ "endure_stress",
119
+ "make_connection",
120
+ "test_bond",
121
+ "resupply",
122
+ "world_shaping",
123
+ ] as const;
124
+
125
+ export const COMBAT_MOVES = new Set(["clash", "strike"]);
126
+ export const SOCIAL_MOVES = new Set(["compel", "make_connection", "test_bond"]);
127
+
128
+ /** Title-case an id like "face_danger" into "Face Danger". */
129
+ function labelFromId(id: string): string {
130
+ return id
131
+ .split("_")
132
+ .map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w))
133
+ .join(" ");
134
+ }
135
+
136
+ // Derived from MOVES so adding a move never means updating a second list.
137
+ export const MOVE_LABELS: Record<(typeof MOVES)[number], string> = Object.fromEntries(
138
+ MOVES.map((m) => [m, labelFromId(m)]),
139
+ ) as Record<(typeof MOVES)[number], string>;
140
+
141
+ // ── Time Phases ──────────────────────────────────────────────────────────────
142
+ export const TIME_PHASES = [
143
+ "early_morning",
144
+ "morning",
145
+ "midday",
146
+ "afternoon",
147
+ "evening",
148
+ "late_evening",
149
+ "night",
150
+ "deep_night",
151
+ ] as const;
152
+
153
+ // ── Chaos Interrupt Types ────────────────────────────────────────────────────
154
+ const CHAOS_INTERRUPT_TYPES = [
155
+ "An NPC arrives unexpectedly",
156
+ "An environmental hazard erupts",
157
+ "A hidden truth is revealed",
158
+ "A complication arises from a past action",
159
+ "A new threat appears on the horizon",
160
+ "An ally changes sides or reveals a secret",
161
+ "Strange phenomena disrupt the scene",
162
+ "A resource is lost or compromised",
163
+ ];
164
+
165
+ // ── Dispositions ─────────────────────────────────────────────────────────────
166
+ export const DISPOSITIONS = ["hostile", "distrustful", "neutral", "friendly", "loyal"] as const;
167
+ export type Disposition = (typeof DISPOSITIONS)[number];
168
+
169
+ // ── NPC Interface ────────────────────────────────────────────────────────────
170
+ export interface NPC {
171
+ id: string;
172
+ name: string;
173
+ description: string;
174
+ disposition: Disposition;
175
+ bond: number;
176
+ agenda: string;
177
+ status: "active" | "background" | "deceased";
178
+ }
179
+
180
+ // ── Clock Interface ──────────────────────────────────────────────────────────
181
+ export interface Clock {
182
+ id: string;
183
+ name: string;
184
+ clockType: "threat" | "progress" | "scheme";
185
+ segments: number;
186
+ filled: number;
187
+ triggerDescription: string;
188
+ }
189
+
190
+ // ── Story Blueprint ──────────────────────────────────────────────────────────
191
+ export interface StoryAct {
192
+ phase: string;
193
+ title: string;
194
+ goal: string;
195
+ mood: string;
196
+ transitionTrigger: string;
197
+ }
198
+
199
+ export interface StoryBlueprint {
200
+ structureType: "3act" | "kishotenketsu";
201
+ centralConflict: string;
202
+ antagonistForce: string;
203
+ thematicThread: string;
204
+ acts: StoryAct[];
205
+ currentAct: number;
206
+ storyComplete: boolean;
207
+ }
208
+
209
+ // ── Session Log Entry ────────────────────────────────────────────────────────
210
+ export interface SessionLogEntry {
211
+ scene: number;
212
+ summary: string;
213
+ location: string;
214
+ }
215
+
216
+ // ── Last Roll (for momentum burn) ────────────────────────────────────────────
217
+ /** Exact state changes a roll applied, recorded so a burn can revert them. */
218
+ export interface ConsequenceDeltas {
219
+ health: number;
220
+ spirit: number;
221
+ supply: number;
222
+ momentum: number;
223
+ npcId: string | null;
224
+ bond: number;
225
+ dispositionFrom: Disposition | null;
226
+ dispositionTo: Disposition | null;
227
+ clockId: string | null;
228
+ clockTicks: number;
229
+ }
230
+
231
+ export interface LastRoll {
232
+ d1: number;
233
+ d2: number;
234
+ c1: number;
235
+ c2: number;
236
+ statName: string;
237
+ statValue: number;
238
+ actionScore: number;
239
+ result: "STRONG_HIT" | "WEAK_HIT" | "MISS";
240
+ move: string;
241
+ match: boolean;
242
+ position: string;
243
+ effect: string;
244
+ targetNpcId: string | null;
245
+ deltas: ConsequenceDeltas;
246
+ }
247
+
248
+ // ── Game State ───────────────────────────────────────────────────────────────
249
+ export interface GameState {
250
+ initialized: boolean;
251
+ phase: "genre" | "playing";
252
+ settingGenre: string;
253
+ settingTone: string;
254
+ settingArchetype: string;
255
+ settingDescription: string;
256
+ playerName: string;
257
+ characterConcept: string;
258
+ backstory: string;
259
+ playerWishes: string;
260
+ contentLines: string;
261
+ edge: number;
262
+ heart: number;
263
+ iron: number;
264
+ shadow: number;
265
+ wits: number;
266
+ health: number;
267
+ spirit: number;
268
+ supply: number;
269
+ momentum: number;
270
+ maxMomentum: number;
271
+ sceneCount: number;
272
+ currentLocation: string;
273
+ currentSceneContext: string;
274
+ timeOfDay: string;
275
+ chaosFactor: number;
276
+ crisisMode: boolean;
277
+ gameOver: boolean;
278
+ npcs: NPC[];
279
+ clocks: Clock[];
280
+ storyBlueprint: StoryBlueprint | null;
281
+ sessionLog: SessionLogEntry[];
282
+ lastRoll: LastRoll | null;
283
+ kidMode: boolean;
284
+ }
285
+
286
+ export const DEFAULT_STATE: GameState = {
287
+ initialized: false,
288
+ phase: "genre",
289
+ settingGenre: "",
290
+ settingTone: "",
291
+ settingArchetype: "",
292
+ settingDescription: "",
293
+ playerName: "",
294
+ characterConcept: "",
295
+ backstory: "",
296
+ playerWishes: "",
297
+ contentLines: "",
298
+ edge: 1,
299
+ heart: 2,
300
+ iron: 1,
301
+ shadow: 1,
302
+ wits: 2,
303
+ health: 5,
304
+ spirit: 5,
305
+ supply: 5,
306
+ momentum: MOMENTUM_RESET,
307
+ maxMomentum: 10,
308
+ sceneCount: 0,
309
+ currentLocation: "",
310
+ currentSceneContext: "",
311
+ timeOfDay: "",
312
+ chaosFactor: 5,
313
+ crisisMode: false,
314
+ gameOver: false,
315
+ npcs: [],
316
+ clocks: [],
317
+ storyBlueprint: null,
318
+ sessionLog: [],
319
+ lastRoll: null,
320
+ kidMode: false,
321
+ };
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 };
328
+
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
+ }
341
+
342
+ // ── Persistent save slots (ctx.db) ───────────────────────────────────────────
343
+ // save_game / load_game are genuine cross-session persistence, so they use
344
+ // the app's SQL database. Requires storage: `aai storage enable` (or the
345
+ // Storage toggle in the studio); under `aai dev`, set DATABASE_URL in .env.
346
+ //
347
+ // Slots are keyed by name alone — the whole point of a save is loading it in
348
+ // a LATER session, whose sessionId differs, so the key can't embed one. The
349
+ // storage is per app, so every player of one deployment shares the slot
350
+ // namespace; without player identity that is the price of resumability.
351
+ export function saveSlotKey(slot?: string): string {
352
+ return `save:${slot ?? "autosave"}`;
353
+ }
354
+
355
+ /** The slot-name grammar, shared by save_game and load_game so a name that
356
+ * can be saved can always be loaded. */
357
+ export const saveSlotParam = z
358
+ .string()
359
+ .regex(/^[A-Za-z0-9_-]{1,32}$/, "letters, digits, dashes, underscores; max 32 chars")
360
+ .describe("Save slot name, defaults to autosave")
361
+ .optional();
362
+
363
+ const ENSURE_APP_STATE = `create table if not exists app_state (
364
+ key text primary key,
365
+ value jsonb not null,
366
+ updated_at timestamptz not null default now()
367
+ )`;
368
+
369
+ // Memoized per process (each session's tools run in a fresh sandbox, so this
370
+ // is at most one round-trip per session); a failure clears the memo so the
371
+ // next call retries instead of caching the error forever.
372
+ let ensureP: Promise<unknown> | null = null;
373
+ function ensureTable(ctx: ToolContext): Promise<unknown> {
374
+ ensureP ??= ctx.db.query(ENSURE_APP_STATE).catch((err) => {
375
+ ensureP = null;
376
+ throw err;
377
+ });
378
+ return ensureP;
379
+ }
380
+
381
+ /** Read one saved value. jsonb columns come back from the postgres driver
382
+ * already parsed, so the value needs no JSON.parse here. */
383
+ export async function loadState<T>(ctx: ToolContext, key: string): Promise<T | null> {
384
+ await ensureTable(ctx);
385
+ const rows = await ctx.db.query<{ value: T }>("select value from app_state where key = $1", [
386
+ key,
387
+ ]);
388
+ return rows[0]?.value ?? null;
389
+ }
390
+
391
+ /** Upsert one value. Serialized explicitly and cast with `::jsonb` so the
392
+ * write is driver-agnostic about object parameters. */
393
+ export async function saveState(ctx: ToolContext, key: string, value: unknown): Promise<void> {
394
+ await ensureTable(ctx);
395
+ await ctx.db.query(
396
+ "insert into app_state (key, value, updated_at) values ($1, $2::jsonb, now()) " +
397
+ "on conflict (key) do update set value = excluded.value, updated_at = now()",
398
+ [key, JSON.stringify(value)],
399
+ );
400
+ }
401
+
402
+ // ── Helpers ──────────────────────────────────────────────────────────────────
403
+ export function d(sides: number): number {
404
+ return Math.floor(Math.random() * sides) + 1;
405
+ }
406
+
407
+ export function pick<T>(arr: readonly T[]): T {
408
+ return arr[Math.floor(Math.random() * arr.length)] as T;
409
+ }
410
+
411
+ /**
412
+ * Next sequential id for `prefix` — a max-scan, so removing an item never
413
+ * causes a later id collision (unlike a length+1 counter).
414
+ */
415
+ export function nextSeqId(items: readonly { id: string }[], prefix: string): string {
416
+ let max = 0;
417
+ const re = new RegExp(`^${prefix}_(\\d+)$`);
418
+ for (const item of items) {
419
+ const m = item.id.match(re);
420
+ if (m) max = Math.max(max, Number.parseInt(m[1]!, 10));
421
+ }
422
+ return `${prefix}_${max + 1}`;
423
+ }
424
+
425
+ export function makeNpc(opts: {
426
+ id: string;
427
+ name: string;
428
+ description?: string | undefined;
429
+ disposition?: Disposition | undefined;
430
+ agenda?: string | undefined;
431
+ }): NPC {
432
+ const disposition = opts.disposition ?? "neutral";
433
+ return {
434
+ id: opts.id,
435
+ name: opts.name,
436
+ description: opts.description ?? "",
437
+ disposition,
438
+ bond: disposition === "friendly" ? 1 : disposition === "loyal" ? 2 : 0,
439
+ agenda: opts.agenda ?? "",
440
+ status: "active",
441
+ };
442
+ }
443
+
444
+ export function clockSummary(c: Clock) {
445
+ return {
446
+ id: c.id,
447
+ name: c.name,
448
+ clockType: c.clockType,
449
+ segments: c.segments,
450
+ filled: c.filled,
451
+ full: c.filled >= c.segments,
452
+ triggerDescription: c.triggerDescription,
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Single source of truth for the state snapshot returned to the LLM by
458
+ * setup_character, update_state, and check_state. Includes the player's
459
+ * content boundaries (contentLines) so they survive past the setup turn.
460
+ */
461
+ export function stateSummary(state: GameState) {
462
+ return {
463
+ initialized: state.initialized,
464
+ phase: state.phase,
465
+ settingGenre: state.settingGenre,
466
+ settingTone: state.settingTone,
467
+ settingArchetype: state.settingArchetype,
468
+ settingDescription: state.settingDescription,
469
+ playerName: state.playerName,
470
+ characterConcept: state.characterConcept,
471
+ backstory: state.backstory,
472
+ playerWishes: state.playerWishes,
473
+ contentLines: state.contentLines,
474
+ kidMode: state.kidMode,
475
+ edge: state.edge,
476
+ heart: state.heart,
477
+ iron: state.iron,
478
+ shadow: state.shadow,
479
+ wits: state.wits,
480
+ health: state.health,
481
+ spirit: state.spirit,
482
+ supply: state.supply,
483
+ momentum: state.momentum,
484
+ maxMomentum: state.maxMomentum,
485
+ sceneCount: state.sceneCount,
486
+ currentLocation: state.currentLocation,
487
+ currentSceneContext: state.currentSceneContext,
488
+ timeOfDay: state.timeOfDay,
489
+ chaosFactor: state.chaosFactor,
490
+ crisisMode: state.crisisMode,
491
+ gameOver: state.gameOver,
492
+ // NPCs go to the LLM whole (nothing withheld) — copied so a mutation of
493
+ // the summary can't reach live state.
494
+ npcs: state.npcs.filter((n) => n.status !== "deceased").map((n) => ({ ...n })),
495
+ clocks: state.clocks.map(clockSummary),
496
+ storyBlueprint: state.storyBlueprint
497
+ ? {
498
+ structureType: state.storyBlueprint.structureType,
499
+ currentAct: state.storyBlueprint.currentAct,
500
+ totalActs: state.storyBlueprint.acts.length,
501
+ centralConflict: state.storyBlueprint.centralConflict,
502
+ thematicThread: state.storyBlueprint.thematicThread,
503
+ storyComplete: state.storyBlueprint.storyComplete,
504
+ currentPhase: state.storyBlueprint.acts[state.storyBlueprint.currentAct - 1]?.phase,
505
+ }
506
+ : null,
507
+ recentLog: state.sessionLog.slice(-5),
508
+ };
509
+ }
510
+
511
+ // ── Dice System ──────────────────────────────────────────────────────────────
512
+ export function rollAction(statName: string, statValue: number, move: string) {
513
+ const d1 = d(6),
514
+ d2 = d(6);
515
+ const c1 = d(10),
516
+ c2 = d(10);
517
+ const actionScore = Math.min(d1 + d2 + statValue, 10);
518
+ let result: "STRONG_HIT" | "WEAK_HIT" | "MISS";
519
+ if (actionScore > c1 && actionScore > c2) result = "STRONG_HIT";
520
+ else if (actionScore > c1 || actionScore > c2) result = "WEAK_HIT";
521
+ else result = "MISS";
522
+ const match = c1 === c2;
523
+ return { d1, d2, c1, c2, statName, statValue, actionScore, result, move, match };
524
+ }
525
+
526
+ export type RollResult = ReturnType<typeof rollAction>;
527
+
528
+ // ── Chaos Factor ─────────────────────────────────────────────────────────────
529
+ export function updateChaosFactor(game: GameState, result: string) {
530
+ if (result === "MISS") game.chaosFactor = Math.min(9, game.chaosFactor + 1);
531
+ else if (result === "STRONG_HIT") game.chaosFactor = Math.max(3, game.chaosFactor - 1);
532
+ }
533
+
534
+ export function checkChaosInterrupt(game: GameState): string | null {
535
+ const threshold = game.chaosFactor - 3;
536
+ if (threshold <= 0) return null;
537
+ const roll = d(10);
538
+ if (roll <= threshold) {
539
+ game.chaosFactor = Math.max(3, game.chaosFactor - 1);
540
+ return pick(CHAOS_INTERRUPT_TYPES);
541
+ }
542
+ return null;
543
+ }
544
+
545
+ // ── Consequences ─────────────────────────────────────────────────────────────
546
+ function emptyDeltas(): ConsequenceDeltas {
547
+ return {
548
+ health: 0,
549
+ spirit: 0,
550
+ supply: 0,
551
+ momentum: 0,
552
+ npcId: null,
553
+ bond: 0,
554
+ dispositionFrom: null,
555
+ dispositionTo: null,
556
+ clockId: null,
557
+ clockTicks: 0,
558
+ };
559
+ }
560
+
561
+ function loseResource(
562
+ game: GameState,
563
+ resource: "health" | "spirit" | "supply",
564
+ dmg: number,
565
+ consequences: string[],
566
+ deltas: ConsequenceDeltas,
567
+ ): void {
568
+ const old = game[resource];
569
+ game[resource] = Math.max(0, game[resource] - dmg);
570
+ deltas[resource] += game[resource] - old;
571
+ if (game[resource] < old) consequences.push(`${resource} -${old - game[resource]}`);
572
+ }
573
+
574
+ function changeMomentum(game: GameState, amount: number, deltas: ConsequenceDeltas): void {
575
+ const old = game.momentum;
576
+ game.momentum = Math.max(MIN_MOMENTUM, Math.min(game.maxMomentum, game.momentum + amount));
577
+ deltas.momentum += game.momentum - old;
578
+ }
579
+
580
+ export function updateCrisisFlags(game: GameState): void {
581
+ if (game.health <= 0 && game.spirit <= 0) {
582
+ game.gameOver = true;
583
+ game.crisisMode = true;
584
+ } else if (game.health <= 0 || game.spirit <= 0) {
585
+ game.crisisMode = true;
586
+ } else {
587
+ game.crisisMode = false;
588
+ }
589
+ }
590
+
591
+ export function applyConsequences(
592
+ game: GameState,
593
+ roll: Pick<RollResult, "result" | "move">,
594
+ position: string,
595
+ effect: string,
596
+ targetNpcId: string | null,
597
+ ): {
598
+ consequences: string[];
599
+ clockEvents: { clock: string; trigger: string }[];
600
+ deltas: ConsequenceDeltas;
601
+ } {
602
+ const consequences: string[] = [];
603
+ const clockEvents: { clock: string; trigger: string }[] = [];
604
+ const deltas = emptyDeltas();
605
+ const target = targetNpcId ? game.npcs.find((n) => n.id === targetNpcId) : null;
606
+
607
+ if (roll.result === "MISS") {
608
+ if (roll.move === "endure_harm") {
609
+ loseResource(game, "health", position === "desperate" ? 2 : 1, consequences, deltas);
610
+ } else if (roll.move === "endure_stress") {
611
+ loseResource(game, "spirit", position === "desperate" ? 2 : 1, consequences, deltas);
612
+ } else if (COMBAT_MOVES.has(roll.move)) {
613
+ const dmg = position === "desperate" ? 3 : position === "controlled" ? 1 : 2;
614
+ loseResource(game, "health", dmg, consequences, deltas);
615
+ } else if (SOCIAL_MOVES.has(roll.move)) {
616
+ if (target) {
617
+ const oldBond = target.bond;
618
+ target.bond = Math.max(0, target.bond - 1);
619
+ deltas.npcId = target.id;
620
+ deltas.bond = target.bond - oldBond;
621
+ if (target.bond < oldBond) consequences.push(`${target.name} bond -1`);
622
+ }
623
+ loseResource(game, "spirit", position === "desperate" ? 2 : 1, consequences, deltas);
624
+ } else {
625
+ loseResource(game, "supply", 1, consequences, deltas);
626
+ if (position === "desperate") {
627
+ loseResource(game, "health", 2, consequences, deltas);
628
+ } else if (position !== "controlled") {
629
+ loseResource(game, "health", 1, consequences, deltas);
630
+ }
631
+ }
632
+
633
+ const momLoss = position === "desperate" ? 3 : 2;
634
+ changeMomentum(game, -momLoss, deltas);
635
+ consequences.push(`momentum -${momLoss}`);
636
+
637
+ for (const clock of game.clocks) {
638
+ if (clock.clockType === "threat" && clock.filled < clock.segments) {
639
+ const ticks = position === "desperate" ? 2 : 1;
640
+ const oldFilled = clock.filled;
641
+ clock.filled = Math.min(clock.segments, clock.filled + ticks);
642
+ deltas.clockId = clock.id;
643
+ deltas.clockTicks = clock.filled - oldFilled;
644
+ if (clock.filled >= clock.segments) {
645
+ clockEvents.push({ clock: clock.name, trigger: clock.triggerDescription });
646
+ }
647
+ break;
648
+ }
649
+ }
650
+ } else if (roll.result === "WEAK_HIT") {
651
+ changeMomentum(game, 1, deltas);
652
+ if (roll.move === "make_connection" && target) {
653
+ const oldBond = target.bond;
654
+ target.bond = Math.min(MAX_BOND, target.bond + 1);
655
+ deltas.npcId = target.id;
656
+ deltas.bond = target.bond - oldBond;
657
+ }
658
+ } else {
659
+ const momGain = effect === "great" ? 3 : 2;
660
+ changeMomentum(game, momGain, deltas);
661
+ if ((roll.move === "make_connection" || roll.move === "compel") && target) {
662
+ const oldBond = target.bond;
663
+ target.bond = Math.min(MAX_BOND, target.bond + 1);
664
+ deltas.npcId = target.id;
665
+ deltas.bond = target.bond - oldBond;
666
+ const shifts: Record<string, Disposition> = {
667
+ hostile: "distrustful",
668
+ distrustful: "neutral",
669
+ neutral: "friendly",
670
+ friendly: "loyal",
671
+ };
672
+ const nextDisposition = shifts[target.disposition];
673
+ if (nextDisposition) {
674
+ deltas.dispositionFrom = target.disposition;
675
+ deltas.dispositionTo = nextDisposition;
676
+ target.disposition = nextDisposition;
677
+ }
678
+ }
679
+ }
680
+
681
+ updateCrisisFlags(game);
682
+
683
+ return { consequences, clockEvents, deltas };
684
+ }
685
+
686
+ /**
687
+ * Exactly undo the state changes a roll applied (recorded in its deltas).
688
+ * Used by burn_momentum before re-applying the upgraded result.
689
+ */
690
+ export function revertConsequences(game: GameState, deltas: ConsequenceDeltas): void {
691
+ game.health = Math.max(0, Math.min(MAX_RESOURCE, game.health - deltas.health));
692
+ game.spirit = Math.max(0, Math.min(MAX_RESOURCE, game.spirit - deltas.spirit));
693
+ game.supply = Math.max(0, Math.min(MAX_RESOURCE, game.supply - deltas.supply));
694
+ game.momentum = Math.max(
695
+ MIN_MOMENTUM,
696
+ Math.min(game.maxMomentum, game.momentum - deltas.momentum),
697
+ );
698
+ if (deltas.npcId) {
699
+ const npc = game.npcs.find((n) => n.id === deltas.npcId);
700
+ if (npc) {
701
+ npc.bond = Math.max(0, Math.min(MAX_BOND, npc.bond - deltas.bond));
702
+ if (deltas.dispositionFrom) npc.disposition = deltas.dispositionFrom;
703
+ }
704
+ }
705
+ if (deltas.clockId) {
706
+ const clock = game.clocks.find((c) => c.id === deltas.clockId);
707
+ if (clock) clock.filled = Math.max(0, clock.filled - deltas.clockTicks);
708
+ }
709
+ updateCrisisFlags(game);
710
+ }
711
+
712
+ // ── Momentum Burn ────────────────────────────────────────────────────────────
713
+ export function canBurnMomentum(
714
+ game: GameState,
715
+ roll: Pick<RollResult, "result" | "c1" | "c2">,
716
+ ): "STRONG_HIT" | "WEAK_HIT" | null {
717
+ if (game.momentum <= 0) return null;
718
+ if (roll.result === "MISS" && game.momentum > roll.c1 && game.momentum > roll.c2)
719
+ return "STRONG_HIT";
720
+ if (roll.result === "MISS" && (game.momentum > roll.c1 || game.momentum > roll.c2))
721
+ return "WEAK_HIT";
722
+ if (roll.result === "WEAK_HIT" && game.momentum > roll.c1 && game.momentum > roll.c2)
723
+ return "STRONG_HIT";
724
+ return null;
725
+ }
726
+
727
+ // ── Kishotenketsu Probability ────────────────────────────────────────────────
728
+ const KISHOTENKETSU_PROB: Record<string, number> = {
729
+ melancholic: 0.5,
730
+ cozy: 0.4,
731
+ romantic: 0.35,
732
+ tragicomic: 0.3,
733
+ slow_burn_horror: 0.25,
734
+ cheerful_funny: 0.2,
735
+ absurd_grotesque: 0.2,
736
+ };
737
+
738
+ export function chooseStoryStructure(tone: string): "3act" | "kishotenketsu" {
739
+ const prob = KISHOTENKETSU_PROB[tone] ?? 0.1;
740
+ return Math.random() < prob ? "kishotenketsu" : "3act";
741
+ }
742
+
743
+ export const RESULT_LABELS: Record<string, string> = {
744
+ STRONG_HIT: "Strong Hit",
745
+ WEAK_HIT: "Weak Hit",
746
+ MISS: "Miss",
747
+ };