@alexkroman1/aai-cli 0.9.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/_build-p1HHkdon.mjs +132 -0
  3. package/dist/_discover-BzlCDVZ6.mjs +161 -0
  4. package/dist/_init-l_uoyFCN.mjs +82 -0
  5. package/dist/_link-BGXGFYWa.mjs +47 -0
  6. package/dist/_server-common-qLA1QU2C.mjs +36 -0
  7. package/dist/_ui-kJIua5L9.mjs +44 -0
  8. package/dist/cli.mjs +318 -0
  9. package/dist/deploy-KyNJaoP5.mjs +86 -0
  10. package/dist/dev-DBFvKyzk.mjs +39 -0
  11. package/dist/init-BWG5OrQa.mjs +65 -0
  12. package/dist/rag-BnCMnccf.mjs +173 -0
  13. package/dist/secret-CzeHIGzE.mjs +50 -0
  14. package/dist/start-C1qkhU4O.mjs +23 -0
  15. package/package.json +39 -0
  16. package/templates/_shared/.env.example +5 -0
  17. package/templates/_shared/CLAUDE.md +1051 -0
  18. package/templates/_shared/biome.json +32 -0
  19. package/templates/_shared/global.d.ts +1 -0
  20. package/templates/_shared/index.html +16 -0
  21. package/templates/_shared/package.json +23 -0
  22. package/templates/_shared/tsconfig.json +15 -0
  23. package/templates/code-interpreter/agent.ts +27 -0
  24. package/templates/code-interpreter/client.tsx +3 -0
  25. package/templates/css.d.ts +1 -0
  26. package/templates/dispatch-center/agent.ts +1227 -0
  27. package/templates/dispatch-center/client.tsx +505 -0
  28. package/templates/embedded-assets/agent.ts +48 -0
  29. package/templates/embedded-assets/client.tsx +3 -0
  30. package/templates/embedded-assets/knowledge.json +20 -0
  31. package/templates/health-assistant/agent.ts +160 -0
  32. package/templates/health-assistant/client.tsx +3 -0
  33. package/templates/infocom-adventure/agent.ts +164 -0
  34. package/templates/infocom-adventure/client.tsx +300 -0
  35. package/templates/math-buddy/agent.ts +21 -0
  36. package/templates/math-buddy/client.tsx +3 -0
  37. package/templates/memory-agent/agent.ts +20 -0
  38. package/templates/memory-agent/client.tsx +3 -0
  39. package/templates/night-owl/agent.ts +98 -0
  40. package/templates/night-owl/client.tsx +12 -0
  41. package/templates/personal-finance/agent.ts +26 -0
  42. package/templates/personal-finance/client.tsx +3 -0
  43. package/templates/pizza-ordering/agent.ts +218 -0
  44. package/templates/pizza-ordering/client.tsx +264 -0
  45. package/templates/simple/agent.ts +6 -0
  46. package/templates/simple/client.tsx +3 -0
  47. package/templates/smart-research/agent.ts +164 -0
  48. package/templates/smart-research/client.tsx +3 -0
  49. package/templates/solo-rpg/agent.ts +1244 -0
  50. package/templates/solo-rpg/client.tsx +698 -0
  51. package/templates/support/README.md +62 -0
  52. package/templates/support/agent.ts +19 -0
  53. package/templates/support/client.tsx +3 -0
  54. package/templates/travel-concierge/agent.ts +29 -0
  55. package/templates/travel-concierge/client.tsx +3 -0
  56. package/templates/tsconfig.json +1 -0
  57. package/templates/web-researcher/agent.ts +17 -0
  58. package/templates/web-researcher/client.tsx +3 -0
@@ -0,0 +1,1244 @@
1
+ import { defineAgent, type ToolDef } from "@alexkroman1/aai";
2
+ import type { HookContext } from "@alexkroman1/aai";
3
+ import { z } from "zod";
4
+
5
+ // ── Tuning Constants ─────────────────────────────────────────────────────────
6
+ const STAT_TARGET_SUM = 7;
7
+ const MAX_ACTIVE_NPCS = 12;
8
+ const MAX_SESSION_LOG = 50;
9
+ const MAX_NARRATION_HISTORY = 6;
10
+ const DIRECTOR_INTERVAL = 3;
11
+
12
+ // ── Creativity Seeds ─────────────────────────────────────────────────────────
13
+ const SEED_WORDS = [
14
+ "amber","coyote","furnace","silk","glacier","compass","terracotta","jasmine",
15
+ "anvil","cobalt","driftwood","saffron","limestone","falcon","obsidian","cedar",
16
+ "mercury","lantern","basalt","thistle","copper","monsoon","flint","orchid",
17
+ "pewter","canyon","quartz","ember","mahogany","coral",
18
+ ];
19
+
20
+ function creativitySeed(n = 3): string {
21
+ const shuffled = [...SEED_WORDS].sort(() => Math.random() - 0.5);
22
+ return shuffled.slice(0, n).join(" ");
23
+ }
24
+
25
+ // ── Genres, Tones, Archetypes (from engine i18n.py) ───────────────────────
26
+ const GENRES = {
27
+ dark_fantasy: "Dark Fantasy",
28
+ high_fantasy: "High Fantasy",
29
+ science_fiction: "Sci-Fi",
30
+ horror_mystery: "Horror / Mystery",
31
+ steampunk: "Steampunk",
32
+ cyberpunk: "Cyberpunk",
33
+ urban_fantasy: "Urban Fantasy",
34
+ victorian_crime: "Victorian Crime",
35
+ historical_roman: "Historical / Roman",
36
+ fairy_tale: "Fairy Tale World",
37
+ slice_of_life_90s: "Slice of Life 1990s",
38
+ outdoor_survival: "Outdoor Survival",
39
+ } as const;
40
+
41
+ const TONES = {
42
+ dark_gritty: "Dark & Gritty",
43
+ serious_balanced: "Serious but Fair",
44
+ melancholic: "Melancholic",
45
+ absurd_grotesque: "Absurd & Grotesque",
46
+ slow_burn_horror: "Slow-Burn Horror",
47
+ cheerful_funny: "Cheerful & Fun",
48
+ romantic: "Romantic",
49
+ slapstick: "Slapstick",
50
+ epic_heroic: "Epic & Heroic",
51
+ tarantino: "Tarantino-Style",
52
+ cozy: "Cozy & Comfy",
53
+ tragicomic: "Tragicomic",
54
+ } as const;
55
+
56
+ const ARCHETYPES = {
57
+ outsider_loner: "Outsider / Loner",
58
+ investigator: "Investigator / Curious",
59
+ trickster: "Trickster / Charmer",
60
+ protector: "Protector / Warrior",
61
+ hardboiled: "Hardboiled / Veteran",
62
+ scholar: "Scholar / Mystic",
63
+ healer: "Healer / Medic",
64
+ inventor: "Crafter / Inventor",
65
+ artist: "Artist / Bard",
66
+ } as const;
67
+
68
+ // ── Moves (from engine engine.py BRAIN_OUTPUT_SCHEMA) ─────────────────────
69
+ const MOVES = [
70
+ "face_danger","compel","gather_information","secure_advantage",
71
+ "clash","strike","endure_harm","endure_stress",
72
+ "make_connection","test_bond","resupply","world_shaping","dialog",
73
+ ] as const;
74
+
75
+ const COMBAT_MOVES = new Set(["clash", "strike"]);
76
+ const SOCIAL_MOVES = new Set(["compel", "make_connection", "test_bond"]);
77
+
78
+ const MOVE_LABELS: Record<string, string> = {
79
+ face_danger: "Face Danger", compel: "Compel",
80
+ gather_information: "Gather Information", secure_advantage: "Secure Advantage",
81
+ clash: "Clash", strike: "Strike",
82
+ endure_harm: "Endure Harm", endure_stress: "Endure Stress",
83
+ make_connection: "Make Connection", test_bond: "Test Bond",
84
+ resupply: "Resupply", world_shaping: "World Shaping", dialog: "Dialog",
85
+ };
86
+
87
+ // ── Time Phases (from engine engine.py) ───────────────────────────────────
88
+ const TIME_PHASES = [
89
+ "early_morning","morning","midday","afternoon",
90
+ "evening","late_evening","night","deep_night",
91
+ ] as const;
92
+
93
+ const TIME_LABELS: Record<string, string> = {
94
+ early_morning: "Early Morning", morning: "Morning", midday: "Midday",
95
+ afternoon: "Afternoon", evening: "Evening", late_evening: "Late Evening",
96
+ night: "Night", deep_night: "Deep Night",
97
+ };
98
+
99
+ // ── Chaos Interrupt Types (from engine engine.py) ─────────────────────────
100
+ const CHAOS_INTERRUPT_TYPES = [
101
+ "An NPC arrives unexpectedly",
102
+ "An environmental hazard erupts",
103
+ "A hidden truth is revealed",
104
+ "A complication arises from a past action",
105
+ "A new threat appears on the horizon",
106
+ "An ally changes sides or reveals a secret",
107
+ "Strange phenomena disrupt the scene",
108
+ "A resource is lost or compromised",
109
+ ];
110
+
111
+ // ── Disposition System (from engine engine.py) ────────────────────────────
112
+ const DISPOSITIONS = ["hostile","distrustful","neutral","friendly","loyal"] as const;
113
+ type Disposition = typeof DISPOSITIONS[number];
114
+
115
+ const DISPOSITION_LABELS: Record<Disposition, string> = {
116
+ hostile: "Hostile", distrustful: "Distrustful", neutral: "Neutral",
117
+ friendly: "Friendly", loyal: "Loyal",
118
+ };
119
+
120
+ // ── NPC Interface (from engine engine.py GameState.npcs) ──────────────────
121
+ interface NPC {
122
+ id: string;
123
+ name: string;
124
+ description: string;
125
+ disposition: Disposition;
126
+ bond: number; // -3 to +4
127
+ agenda: string;
128
+ instinct: string;
129
+ status: "active" | "background" | "deceased";
130
+ aliases: string[];
131
+ lastMentionScene: number;
132
+ }
133
+
134
+ // ── Clock Interface (from engine engine.py) ───────────────────────────────
135
+ interface Clock {
136
+ id: string;
137
+ name: string;
138
+ clockType: "threat" | "progress" | "scheme";
139
+ segments: number; // 4, 6, 8, 10, or 12
140
+ filled: number;
141
+ triggerDescription: string;
142
+ owner: string; // NPC id or "world"
143
+ }
144
+
145
+ // ── Story Blueprint (from engine STORY_ARCHITECT_OUTPUT_SCHEMA) ───────────
146
+ interface StoryAct {
147
+ phase: string;
148
+ title: string;
149
+ goal: string;
150
+ mood: string;
151
+ transitionTrigger: string;
152
+ }
153
+
154
+ interface Revelation {
155
+ id: string;
156
+ content: string;
157
+ earliestScene: number;
158
+ dramaticWeight: "low" | "medium" | "high" | "critical";
159
+ revealed: boolean;
160
+ }
161
+
162
+ interface StoryBlueprint {
163
+ structureType: "3act" | "kishotenketsu";
164
+ centralConflict: string;
165
+ antagonistForce: string;
166
+ thematicThread: string;
167
+ acts: StoryAct[];
168
+ revelations: Revelation[];
169
+ possibleEndings: { type: string; description: string }[];
170
+ currentAct: number;
171
+ storyComplete: boolean;
172
+ }
173
+
174
+ // ── Session Log Entry (from engine engine.py session_log) ─────────────────
175
+ interface SessionLogEntry {
176
+ scene: number;
177
+ summary: string;
178
+ richSummary?: string;
179
+ location: string;
180
+ move?: string;
181
+ result?: string;
182
+ }
183
+
184
+ // ── Game State (from engine engine.py GameState dataclass) ─────────────────
185
+ interface GameState {
186
+ initialized: boolean;
187
+ phase: "genre" | "tone" | "archetype" | "name" | "details" | "playing";
188
+
189
+ // Character creation choices
190
+ settingGenre: string;
191
+ settingTone: string;
192
+ settingArchetype: string;
193
+ settingDescription: string;
194
+
195
+ // Character
196
+ playerName: string;
197
+ characterConcept: string;
198
+ backstory: string;
199
+ playerWishes: string;
200
+ contentLines: string;
201
+
202
+ // Stats (0-3, total = 7)
203
+ edge: number;
204
+ heart: number;
205
+ iron: number;
206
+ shadow: number;
207
+ wits: number;
208
+
209
+ // Tracks (0-5)
210
+ health: number;
211
+ spirit: number;
212
+ supply: number;
213
+
214
+ // Momentum (-6 to +10)
215
+ momentum: number;
216
+ maxMomentum: number;
217
+
218
+ // Scene tracking
219
+ sceneCount: number;
220
+ currentLocation: string;
221
+ currentSceneContext: string;
222
+ timeOfDay: string;
223
+ locationHistory: string[];
224
+
225
+ // Chaos Factor (3-9, from Mythic GME)
226
+ chaosFactor: number;
227
+
228
+ // Crisis
229
+ crisisMode: boolean;
230
+ gameOver: boolean;
231
+
232
+ // NPCs
233
+ npcs: NPC[];
234
+
235
+ // Clocks
236
+ clocks: Clock[];
237
+
238
+ // Story
239
+ storyBlueprint: StoryBlueprint | null;
240
+ chapterNumber: number;
241
+ campaignHistory: { title: string; summary: string }[];
242
+
243
+ // Session log
244
+ sessionLog: SessionLogEntry[];
245
+ narrationHistory: string[];
246
+
247
+ // Director guidance
248
+ directorGuidance: {
249
+ narratorGuidance?: string;
250
+ pacing?: string;
251
+ arcNotes?: string;
252
+ };
253
+
254
+ // Kid mode
255
+ kidMode: boolean;
256
+ }
257
+
258
+ const defaultState: GameState = {
259
+ initialized: false,
260
+ phase: "genre",
261
+ settingGenre: "",
262
+ settingTone: "",
263
+ settingArchetype: "",
264
+ settingDescription: "",
265
+ playerName: "",
266
+ characterConcept: "",
267
+ backstory: "",
268
+ playerWishes: "",
269
+ contentLines: "",
270
+ edge: 1, heart: 2, iron: 1, shadow: 1, wits: 2,
271
+ health: 5, spirit: 5, supply: 5,
272
+ momentum: 2, maxMomentum: 10,
273
+ sceneCount: 0,
274
+ currentLocation: "",
275
+ currentSceneContext: "",
276
+ timeOfDay: "",
277
+ locationHistory: [],
278
+ chaosFactor: 5,
279
+ crisisMode: false,
280
+ gameOver: false,
281
+ npcs: [],
282
+ clocks: [],
283
+ storyBlueprint: null,
284
+ chapterNumber: 1,
285
+ campaignHistory: [],
286
+ sessionLog: [],
287
+ narrationHistory: [],
288
+ directorGuidance: {},
289
+ kidMode: false,
290
+ };
291
+
292
+ function gameTool<P extends z.ZodObject<z.ZodRawShape>>(def: ToolDef<P, GameState>): ToolDef<P, GameState> {
293
+ return def;
294
+ }
295
+
296
+ // ── Helpers ──────────────────────────────────────────────────────────────────
297
+ function d(sides: number): number {
298
+ return Math.floor(Math.random() * sides) + 1;
299
+ }
300
+
301
+ function pick<T>(arr: readonly T[]): T {
302
+ return arr[Math.floor(Math.random() * arr.length)] as T;
303
+ }
304
+
305
+ function nextNpcId(npcs: NPC[]): string {
306
+ let max = 0;
307
+ for (const n of npcs) {
308
+ const m = n.id.match(/^npc_(\d+)$/);
309
+ if (m) max = Math.max(max, parseInt(m[1]!));
310
+ }
311
+ return `npc_${max + 1}`;
312
+ }
313
+
314
+ // ── Dice System (from engine engine.py roll_action) ───────────────────────
315
+ // 2d6 + stat (capped at 10) vs 2d10
316
+ function rollAction(statName: string, statValue: number, move: string) {
317
+ const d1 = d(6), d2 = d(6);
318
+ const c1 = d(10), c2 = d(10);
319
+ const actionScore = Math.min(d1 + d2 + statValue, 10);
320
+ let result: "STRONG_HIT" | "WEAK_HIT" | "MISS";
321
+ if (actionScore > c1 && actionScore > c2) result = "STRONG_HIT";
322
+ else if (actionScore > c1 || actionScore > c2) result = "WEAK_HIT";
323
+ else result = "MISS";
324
+ const match = c1 === c2;
325
+ return { d1, d2, c1, c2, statName, statValue, actionScore, result, move, match };
326
+ }
327
+
328
+ type RollResult = ReturnType<typeof rollAction>;
329
+
330
+ // ── Chaos Factor (from engine engine.py) ──────────────────────────────────
331
+ function updateChaosFactor(game: GameState, result: string) {
332
+ if (result === "MISS") game.chaosFactor = Math.min(9, game.chaosFactor + 1);
333
+ else if (result === "STRONG_HIT") game.chaosFactor = Math.max(3, game.chaosFactor - 1);
334
+ }
335
+
336
+ function checkChaosInterrupt(game: GameState): string | null {
337
+ const threshold = game.chaosFactor - 3;
338
+ if (threshold <= 0) return null;
339
+ const roll = d(10);
340
+ if (roll <= threshold) {
341
+ game.chaosFactor = Math.max(3, game.chaosFactor - 1);
342
+ return pick(CHAOS_INTERRUPT_TYPES);
343
+ }
344
+ return null;
345
+ }
346
+
347
+ // ── Time Advancement (from engine engine.py) ──────────────────────────────
348
+ function advanceTime(game: GameState, progression: string) {
349
+ if (!game.timeOfDay || progression === "none" || progression === "short") return;
350
+ const idx = (TIME_PHASES as readonly string[]).indexOf(game.timeOfDay);
351
+ if (idx === -1) return;
352
+ const steps = progression === "moderate" ? 1 : progression === "long" ? 2 : 0;
353
+ if (steps) {
354
+ const newIdx = (idx + steps) % TIME_PHASES.length;
355
+ game.timeOfDay = TIME_PHASES[newIdx] as string;
356
+ }
357
+ }
358
+
359
+ // ── Consequences (from engine engine.py apply_consequences) ───────────────
360
+ function applyConsequences(
361
+ game: GameState,
362
+ roll: RollResult,
363
+ position: string,
364
+ effect: string,
365
+ targetNpcId: string | null,
366
+ ): { consequences: string[]; clockEvents: { clock: string; trigger: string }[] } {
367
+ const consequences: string[] = [];
368
+ const clockEvents: { clock: string; trigger: string }[] = [];
369
+ const target = targetNpcId ? game.npcs.find(n => n.id === targetNpcId) : null;
370
+
371
+ if (roll.result === "MISS") {
372
+ // Damage based on move type and position
373
+ if (roll.move === "endure_harm") {
374
+ const dmg = position === "desperate" ? 2 : 1;
375
+ const old = game.health;
376
+ game.health = Math.max(0, game.health - dmg);
377
+ if (game.health < old) consequences.push(`health -${old - game.health}`);
378
+ } else if (roll.move === "endure_stress") {
379
+ const dmg = position === "desperate" ? 2 : 1;
380
+ const old = game.spirit;
381
+ game.spirit = Math.max(0, game.spirit - dmg);
382
+ if (game.spirit < old) consequences.push(`spirit -${old - game.spirit}`);
383
+ } else if (COMBAT_MOVES.has(roll.move)) {
384
+ const dmg = position === "desperate" ? 3 : position === "controlled" ? 1 : 2;
385
+ const old = game.health;
386
+ game.health = Math.max(0, game.health - dmg);
387
+ if (game.health < old) consequences.push(`health -${old - game.health}`);
388
+ } else if (SOCIAL_MOVES.has(roll.move)) {
389
+ if (target) {
390
+ const oldBond = target.bond;
391
+ target.bond = Math.max(0, target.bond - 1);
392
+ if (target.bond < oldBond) consequences.push(`${target.name} bond -1`);
393
+ }
394
+ const dmg = position === "desperate" ? 2 : 1;
395
+ const old = game.spirit;
396
+ game.spirit = Math.max(0, game.spirit - dmg);
397
+ if (game.spirit < old) consequences.push(`spirit -${old - game.spirit}`);
398
+ } else {
399
+ // General miss: supply loss + position-scaled health loss
400
+ const oldSupply = game.supply;
401
+ game.supply = Math.max(0, game.supply - 1);
402
+ if (game.supply < oldSupply) consequences.push(`supply -${oldSupply - game.supply}`);
403
+ if (position === "desperate") {
404
+ const oldH = game.health;
405
+ game.health = Math.max(0, game.health - 2);
406
+ if (game.health < oldH) consequences.push(`health -${oldH - game.health}`);
407
+ } else if (position !== "controlled") {
408
+ const oldH = game.health;
409
+ game.health = Math.max(0, game.health - 1);
410
+ if (game.health < oldH) consequences.push(`health -${oldH - game.health}`);
411
+ }
412
+ }
413
+
414
+ // Momentum loss on miss
415
+ const momLoss = position === "desperate" ? 3 : 2;
416
+ game.momentum = Math.max(-6, game.momentum - momLoss);
417
+ consequences.push(`momentum -${momLoss}`);
418
+
419
+ // Advance first threat clock
420
+ for (const clock of game.clocks) {
421
+ if (clock.clockType === "threat" && clock.filled < clock.segments) {
422
+ const ticks = position === "desperate" ? 2 : 1;
423
+ clock.filled = Math.min(clock.segments, clock.filled + ticks);
424
+ if (clock.filled >= clock.segments) {
425
+ clockEvents.push({ clock: clock.name, trigger: clock.triggerDescription });
426
+ }
427
+ break;
428
+ }
429
+ }
430
+ } else if (roll.result === "WEAK_HIT") {
431
+ // Momentum +1 on weak hit
432
+ game.momentum = Math.min(game.maxMomentum, game.momentum + 1);
433
+ if (roll.move === "make_connection" && target) {
434
+ target.bond = Math.min(4, target.bond + 1);
435
+ }
436
+ } else {
437
+ // STRONG_HIT: momentum gain scaled by effect
438
+ const momGain = effect === "great" ? 3 : 2;
439
+ game.momentum = Math.min(game.maxMomentum, game.momentum + momGain);
440
+ if ((roll.move === "make_connection" || roll.move === "compel") && target) {
441
+ target.bond = Math.min(4, target.bond + 1);
442
+ // Disposition shift on strong social hit
443
+ const shifts: Record<string, Disposition> = {
444
+ hostile: "distrustful", distrustful: "neutral",
445
+ neutral: "friendly", friendly: "loyal",
446
+ };
447
+ const nextDisposition = shifts[target.disposition];
448
+ if (nextDisposition) target.disposition = nextDisposition;
449
+ }
450
+ }
451
+
452
+ // Crisis check
453
+ if (game.health <= 0 && game.spirit <= 0) {
454
+ game.gameOver = true;
455
+ game.crisisMode = true;
456
+ } else if (game.health <= 0 || game.spirit <= 0) {
457
+ game.crisisMode = true;
458
+ } else {
459
+ game.crisisMode = false;
460
+ }
461
+
462
+ return { consequences, clockEvents };
463
+ }
464
+
465
+ // ── Momentum Burn (from engine engine.py can_burn_momentum) ───────────────
466
+ function canBurnMomentum(game: GameState, roll: RollResult): string | null {
467
+ if (game.momentum <= 0) return null;
468
+ if (roll.result === "MISS" && game.momentum > roll.c1 && game.momentum > roll.c2) return "STRONG_HIT";
469
+ if (roll.result === "MISS" && (game.momentum > roll.c1 || game.momentum > roll.c2)) return "WEAK_HIT";
470
+ if (roll.result === "WEAK_HIT" && game.momentum > roll.c1 && game.momentum > roll.c2) return "STRONG_HIT";
471
+ return null;
472
+ }
473
+
474
+ // ── Kishotenketsu Probability (from engine engine.py) ─────────────────────
475
+ const KISHOTENKETSU_PROB: Record<string, number> = {
476
+ melancholic: 0.5, cozy: 0.4, romantic: 0.35, tragicomic: 0.3,
477
+ slow_burn_horror: 0.25, cheerful_funny: 0.2, absurd_grotesque: 0.2,
478
+ };
479
+
480
+ function chooseStoryStructure(tone: string): "3act" | "kishotenketsu" {
481
+ const prob = KISHOTENKETSU_PROB[tone] ?? 0.1;
482
+ return Math.random() < prob ? "kishotenketsu" : "3act";
483
+ }
484
+
485
+ // ═══════════════════════════════════════════════════════════════════════════════
486
+ // AGENT DEFINITION
487
+ // ═══════════════════════════════════════════════════════════════════════════════
488
+
489
+ export default defineAgent({
490
+ name: "Solo RPG",
491
+
492
+ instructions: `You are the Narrator of a solo tabletop RPG engine. You guide the player through a narrative adventure using proven game mechanics adapted from Ironsworn/Starforged, Mythic GME, and Blades in the Dark.
493
+
494
+ CHARACTER CREATION — ONE TURN SETUP:
495
+ The player only needs to give you ONE thing to start: a name, a genre, a character idea, or just say "go". That is enough. You fill in the rest.
496
+
497
+ From whatever the player gives you, infer the best genre, tone, and archetype. If they say "cyberpunk hacker named Kai", you have everything. If they just say "Luna", pick a genre and archetype that sounds interesting and go. If they say "surprise me", pick everything yourself.
498
+
499
+ Available genres: ${Object.values(GENRES).join(", ")}.
500
+ Available tones: ${Object.values(TONES).join(", ")}.
501
+ Available archetypes: ${Object.values(ARCHETYPES).join(", ")}.
502
+
503
+ Once you have the player's input, immediately call setup_character with ALL fields filled in: genre, tone, archetype, playerName, characterConcept, settingDescription, startingLocation, locationDesc, timeOfDay, openingSituation, npc1Name, npc1Desc, npc1Disposition, npc1Agenda, threatClockName, threatClockDesc. You must generate all of these yourself based on the player's input. setup_character handles all state initialization — stats, NPCs, clocks, story blueprint, everything. After it returns, just narrate the opening scene. Do NOT call update_state after setup_character — it is already done.
504
+
505
+ Do all of this in ONE turn. Never ask follow-up questions before starting. The player can always change things later by telling you.
506
+
507
+ IMPORTANT FOR SPEECH: Never list more than three or four options. Keep all responses punchy and conversational. No long lists — they sound terrible spoken aloud.
508
+
509
+ CORE MECHANIC - ACTION ROLL (Ironsworn):
510
+ When the player attempts something risky, use the action_roll tool. You choose the move and stat:
511
+ - edge for speed, agility, precision, ranged combat
512
+ - heart for courage, willpower, empathy, leadership
513
+ - iron for strength, endurance, melee combat
514
+ - shadow for stealth, deception, cunning
515
+ - wits for expertise, knowledge, observation
516
+
517
+ The system rolls 2d6 + stat (capped at 10) vs 2d10.
518
+ - Strong Hit: beat both d10s. Clean success.
519
+ - Weak Hit: beat one d10. Success with a cost or complication.
520
+ - Miss: beat neither. Failure with consequences.
521
+ - Match (both d10s same): amplifies the result. Strong Hit + Match = exceptional. Miss + Match = dire escalation.
522
+
523
+ MOVES (12 mechanical actions + dialog):
524
+ - face_danger: overcome obstacles, act under pressure
525
+ - gather_information: search, investigate, observe
526
+ - secure_advantage: prepare, scout, gain edge
527
+ - world_shaping: player introduces new world elements
528
+ - compel: persuade, negotiate, manipulate
529
+ - make_connection: bond with someone, establish relationship
530
+ - test_bond: rely on relationship, call in favor
531
+ - clash: opposed combat (melee/range)
532
+ - strike: attack when opponent cannot react
533
+ - endure_harm: suffer physical damage
534
+ - endure_stress: suffer mental/emotional damage
535
+ - resupply: restore supply track
536
+ - dialog: pure conversation, no risk, no roll
537
+
538
+ POSITION & EFFECT (Blades in the Dark):
539
+ Every risky action has a position and effect:
540
+ Position (how dangerous):
541
+ - Controlled: upper hand, failure is mild
542
+ - Risky: default, real consequences on failure
543
+ - Desperate: in trouble, failure hits hard
544
+
545
+ Effect (what can be achieved):
546
+ - Limited: partial success even on strong hit
547
+ - Standard: full success as described
548
+ - Great: exceeds expectations, bonus outcome
549
+
550
+ Position scales damage on miss. Effect scales momentum gain on strong hit.
551
+
552
+ MOMENTUM (Ironsworn):
553
+ - Starts at 2, range -6 to +10
554
+ - Weak Hit: +1. Strong Hit: +2 (or +3 with great effect)
555
+ - Miss: -2 (or -3 if desperate)
556
+ - Burn: player can spend momentum to upgrade a result if momentum beats both challenge dice. Resets to +2 after burn.
557
+
558
+ CHAOS FACTOR (Mythic GME):
559
+ - Range 3-9, starts at 5
560
+ - Miss: chaos +1 (max 9). Strong Hit: chaos -1 (min 3). Weak Hit: no change.
561
+ - Scene interrupt probability: (chaos - 3) x 10%. Chaos 5 = 20%, chaos 9 = 60%.
562
+ - When interrupt triggers, something unexpected disrupts the scene.
563
+
564
+ CLOCKS (Blades in the Dark):
565
+ Clocks track threats, progress, and NPC schemes:
566
+ - Threat clocks advance on misses. When full, the threat strikes.
567
+ - Progress clocks track long-term goals.
568
+ - Scheme clocks track NPC agendas (advance every 5 scenes).
569
+
570
+ NPCs:
571
+ NPCs have dispositions: hostile, distrustful, neutral, friendly, loyal.
572
+ Social strong hits shift disposition favorably. Social misses damage bonds.
573
+ NPCs have agendas and instincts that drive their behavior.
574
+ Track up to ${MAX_ACTIVE_NPCS} active NPCs.
575
+
576
+ CRISIS:
577
+ - Health or spirit at 0 = crisis mode. Both at 0 = game over.
578
+ - In crisis, every miss is more dangerous.
579
+
580
+ KID MODE:
581
+ If kidMode is true: no explicit violence, no death, hopeful tone, age-appropriate content. Enemies are "defeated" not "killed". Think Studio Ghibli, Zelda.
582
+
583
+ STORY BLUEPRINT:
584
+ The story follows either a 3-act structure or Kishotenketsu (4-part). Created at game start. Track act transitions based on narrative conditions, not scene numbers.
585
+
586
+ CORRECTION SYSTEM:
587
+ If the player starts a message with ##, treat it as a correction to the previous turn. Acknowledge the correction and rewrite the scene.
588
+
589
+ FLOW:
590
+ 1. check_state is automatically forced as your first tool call every turn. Read the returned values as ground truth. NEVER remember or guess stats from prior turns.
591
+ 2. Present situations with tension and choice. Two to three options, but accept anything.
592
+ 3. For ANY risky action, you MUST call action_roll. NEVER narrate success or failure without rolling. NEVER reduce health, spirit, supply, or momentum yourself — action_roll does this through code. If you narrate damage without calling action_roll, the sidebar will be wrong and the game will break.
593
+ 4. After location changes, new NPCs, or other world changes, call update_state. But NEVER manually set health, spirit, supply, or momentum in update_state unless the player is resting or trading — action_roll handles combat and risk.
594
+ 5. The chaos interrupt check happens automatically inside action_roll. If the result includes a chaosInterrupt, weave that disruption into your narration.
595
+ 6. Every ${DIRECTOR_INTERVAL} scenes, consider story arc progression and NPC development via update_state.
596
+
597
+ VOICE:
598
+ - Keep narration to 2-4 sentences. Optimized for spoken conversation.
599
+ - Short, punchy sentences. No visual formatting.
600
+ - Never mention "search results" or "sources".
601
+ - No exclamation points. Calm, conversational tone.
602
+ - One vivid detail per scene. Let the player's imagination do the rest.
603
+ - NPCs speak in character. Brief, natural dialog fragments.
604
+ - Never over-describe. If the player wants more detail, they will ask.
605
+ - Describe consequences naturally within the narration, do not list them.`,
606
+
607
+ greeting:
608
+ "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.",
609
+
610
+ sttPrompt:
611
+ "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",
612
+
613
+ builtinTools: ["run_code"],
614
+ maxSteps: 8,
615
+
616
+ state: () => structuredClone(defaultState),
617
+
618
+ // Auto-load saved game on connect.
619
+ onConnect: async (ctx: HookContext<GameState>) => {
620
+ const saved = await ctx.kv.get<GameState>("save:game");
621
+ if (saved) Object.assign(ctx.state, saved);
622
+ },
623
+
624
+ // Auto-save after every turn so progress persists across browser refreshes.
625
+ onTurn: async (_text: string, ctx: HookContext<GameState>) => {
626
+ if (ctx.state.initialized) {
627
+ await ctx.kv.set("save:game", ctx.state);
628
+ }
629
+ },
630
+
631
+ // Force check_state as the first tool call on every turn after initialization.
632
+ // This ensures the LLM always sees real state and never hallucinates HP, momentum, etc.
633
+ onBeforeStep: (stepNumber: number, ctx: HookContext<GameState>) => {
634
+ if (stepNumber === 1 && ctx.state.initialized) {
635
+ return { activeTools: ["check_state"] };
636
+ }
637
+ return {};
638
+ },
639
+
640
+ tools: {
641
+ check_state: {
642
+ description:
643
+ "Returns the full current game state. This is AUTOMATICALLY forced as the first tool call every turn. Use these numbers as ground truth — never guess or remember stats from previous turns.",
644
+ execute: (_args, ctx) => {
645
+ const s = ctx.state;
646
+ return {
647
+ initialized: s.initialized,
648
+ phase: s.phase,
649
+ settingGenre: s.settingGenre,
650
+ settingTone: s.settingTone,
651
+ settingArchetype: s.settingArchetype,
652
+ playerName: s.playerName,
653
+ characterConcept: s.characterConcept,
654
+ edge: s.edge, heart: s.heart, iron: s.iron, shadow: s.shadow, wits: s.wits,
655
+ health: s.health, spirit: s.spirit, supply: s.supply,
656
+ momentum: s.momentum, maxMomentum: s.maxMomentum,
657
+ sceneCount: s.sceneCount,
658
+ currentLocation: s.currentLocation,
659
+ timeOfDay: s.timeOfDay,
660
+ chaosFactor: s.chaosFactor,
661
+ crisisMode: s.crisisMode,
662
+ gameOver: s.gameOver,
663
+ npcs: s.npcs.filter(n => n.status !== "deceased").map(n => ({
664
+ id: n.id, name: n.name, disposition: n.disposition,
665
+ bond: n.bond, agenda: n.agenda, status: n.status,
666
+ })),
667
+ clocks: s.clocks.filter(c => c.filled < c.segments).map(c => ({
668
+ name: c.name, type: c.clockType,
669
+ filled: c.filled, segments: c.segments,
670
+ })),
671
+ storyAct: s.storyBlueprint ? {
672
+ current: s.storyBlueprint.currentAct,
673
+ total: s.storyBlueprint.acts.length,
674
+ phase: s.storyBlueprint.acts[s.storyBlueprint.currentAct - 1]?.phase,
675
+ complete: s.storyBlueprint.storyComplete,
676
+ } : null,
677
+ kidMode: s.kidMode,
678
+ directorGuidance: s.directorGuidance,
679
+ recentLog: s.sessionLog.slice(-3),
680
+ };
681
+ },
682
+ },
683
+
684
+ setup_character: gameTool({
685
+ description:
686
+ "Set up the entire game in one call. Generates stats, initializes state, and marks the game as ready. After this returns, just narrate the opening scene. No need to call update_state — everything is already done.",
687
+ parameters: z.object({
688
+ genre: z.string().describe("Chosen genre code or custom description"),
689
+ tone: z.string().describe("Chosen tone code or custom description"),
690
+ archetype: z.string().describe("Chosen archetype code or custom description"),
691
+ playerName: z.string().describe("Character name"),
692
+ characterConcept: z.string().describe("One-line character concept"),
693
+ settingDescription: z.string().describe("Two to three sentence setting description"),
694
+ startingLocation: z.string().describe("Name of starting location"),
695
+ locationDesc: z.string().describe("One sentence description of starting location"),
696
+ timeOfDay: z.enum(["early_morning","morning","midday","afternoon","evening","late_evening","night","deep_night"]).describe("Starting time of day"),
697
+ openingSituation: z.string().describe("One sentence dramatic hook for the opening scene"),
698
+ npc1Name: z.string().describe("First NPC name"),
699
+ npc1Desc: z.string().describe("First NPC one-line description"),
700
+ npc1Disposition: z.enum(DISPOSITIONS).describe("First NPC disposition"),
701
+ npc1Agenda: z.string().describe("First NPC agenda"),
702
+ threatClockName: z.string().describe("Name of initial threat clock"),
703
+ threatClockDesc: z.string().describe("What happens when the threat clock fills"),
704
+ threatClockSegments: z.number().optional().describe("Segments for threat clock, default 6"),
705
+ backstory: z.string().optional(),
706
+ wishes: z.string().optional(),
707
+ contentLines: z.string().optional(),
708
+ kidMode: z.boolean().optional(),
709
+ }),
710
+ execute: (args, ctx) => {
711
+ const state = ctx.state;
712
+
713
+ // Store creation choices
714
+ state.settingGenre = args.genre;
715
+ state.settingTone = args.tone;
716
+ state.settingArchetype = args.archetype;
717
+ state.playerName = args.playerName;
718
+ state.characterConcept = args.characterConcept;
719
+ state.settingDescription = args.settingDescription;
720
+ state.backstory = args.backstory || "";
721
+ state.playerWishes = args.wishes || "";
722
+ state.contentLines = args.contentLines || "";
723
+ state.kidMode = args.kidMode || false;
724
+
725
+ // Generate stats: one at 3, two at 2, two at 1 (total = 7)
726
+ const statValues = [3, 2, 2, 1, 1];
727
+ for (let i = statValues.length - 1; i > 0; i--) {
728
+ const j = Math.floor(Math.random() * (i + 1));
729
+ [statValues[i], statValues[j]] = [statValues[j]!, statValues[i]!];
730
+ }
731
+ const archetypeBias: Record<string, number> = {
732
+ outsider_loner: 0, investigator: 4, trickster: 3,
733
+ protector: 2, hardboiled: 2, scholar: 4,
734
+ healer: 1, inventor: 4, artist: 1,
735
+ };
736
+ const biasIdx = archetypeBias[args.archetype] ?? Math.floor(Math.random() * 5);
737
+ const highIdx = statValues.indexOf(3);
738
+ if (highIdx !== biasIdx) {
739
+ [statValues[highIdx], statValues[biasIdx]] = [statValues[biasIdx]!, statValues[highIdx]!];
740
+ }
741
+ state.edge = statValues[0]!;
742
+ state.heart = statValues[1]!;
743
+ state.iron = statValues[2]!;
744
+ state.shadow = statValues[3]!;
745
+ state.wits = statValues[4]!;
746
+
747
+ // Set location, time
748
+ state.currentLocation = args.startingLocation;
749
+ state.currentSceneContext = args.locationDesc;
750
+ state.timeOfDay = args.timeOfDay;
751
+
752
+ // Add initial NPC
753
+ state.npcs.push({
754
+ id: "npc_1",
755
+ name: args.npc1Name,
756
+ description: args.npc1Desc,
757
+ disposition: args.npc1Disposition,
758
+ bond: args.npc1Disposition === "friendly" ? 1 : args.npc1Disposition === "loyal" ? 2 : 0,
759
+ agenda: args.npc1Agenda,
760
+ instinct: "",
761
+ status: "active",
762
+ aliases: [],
763
+ lastMentionScene: 0,
764
+ });
765
+
766
+ // Add threat clock
767
+ state.clocks.push({
768
+ id: "clock_1",
769
+ name: args.threatClockName,
770
+ clockType: "threat",
771
+ segments: args.threatClockSegments || 6,
772
+ filled: 0,
773
+ triggerDescription: args.threatClockDesc,
774
+ owner: "world",
775
+ });
776
+
777
+ // Story blueprint (simple 3-act)
778
+ const structure = chooseStoryStructure(args.tone);
779
+ state.storyBlueprint = {
780
+ structureType: structure,
781
+ centralConflict: args.openingSituation,
782
+ antagonistForce: "",
783
+ thematicThread: "",
784
+ acts: structure === "3act"
785
+ ? [
786
+ { phase: "setup", title: "The Hook", goal: "Establish the world and the conflict", mood: args.tone, transitionTrigger: "Player engages with the central conflict" },
787
+ { phase: "confrontation", title: "Rising Stakes", goal: "Escalate tension and complications", mood: args.tone, transitionTrigger: "A major setback or revelation" },
788
+ { phase: "climax", title: "The Reckoning", goal: "Resolve the central conflict", mood: args.tone, transitionTrigger: "Story reaches its conclusion" },
789
+ ]
790
+ : [
791
+ { phase: "ki_introduction", title: "Ki", goal: "Introduce the world and characters", mood: args.tone, transitionTrigger: "World is established" },
792
+ { phase: "sho_development", title: "Sho", goal: "Develop relationships and deepen the world", mood: args.tone, transitionTrigger: "Relationships are tested" },
793
+ { phase: "ten_twist", title: "Ten", goal: "An unexpected twist changes everything", mood: args.tone, transitionTrigger: "The twist lands" },
794
+ { phase: "ketsu_resolution", title: "Ketsu", goal: "Resolve and reflect", mood: args.tone, transitionTrigger: "Story reaches its conclusion" },
795
+ ],
796
+ revelations: [],
797
+ possibleEndings: [],
798
+ currentAct: 1,
799
+ storyComplete: false,
800
+ };
801
+
802
+ // Mark initialized
803
+ state.initialized = true;
804
+ state.phase = "playing";
805
+ state.sceneCount = 1;
806
+
807
+ return {
808
+ success: true,
809
+ initialized: true,
810
+ playerName: state.playerName,
811
+ characterConcept: state.characterConcept,
812
+ settingGenre: GENRES[args.genre as keyof typeof GENRES] || args.genre,
813
+ settingTone: TONES[args.tone as keyof typeof TONES] || args.tone,
814
+ settingArchetype: ARCHETYPES[args.archetype as keyof typeof ARCHETYPES] || args.archetype,
815
+ settingDescription: state.settingDescription,
816
+ stats: { edge: state.edge, heart: state.heart, iron: state.iron, shadow: state.shadow, wits: state.wits },
817
+ health: 5, spirit: 5, supply: 5, momentum: 2,
818
+ currentLocation: state.currentLocation,
819
+ currentSceneContext: state.currentSceneContext,
820
+ timeOfDay: state.timeOfDay,
821
+ chaosFactor: 5,
822
+ npcs: state.npcs.map(n => ({ id: n.id, name: n.name, disposition: n.disposition, bond: n.bond, agenda: n.agenda, status: n.status, description: n.description })),
823
+ clocks: state.clocks.map(c => ({ id: c.id, name: c.name, clockType: c.clockType, segments: c.segments, filled: c.filled, triggerDescription: c.triggerDescription })),
824
+ storyBlueprint: { structureType: state.storyBlueprint.structureType, currentAct: 1, totalActs: state.storyBlueprint.acts.length, centralConflict: state.storyBlueprint.centralConflict, thematicThread: "", storyComplete: false, currentPhase: state.storyBlueprint.acts[0]!.phase },
825
+ openingSituation: args.openingSituation,
826
+ creativitySeed: creativitySeed(),
827
+ phase: "playing",
828
+ sceneCount: 1,
829
+ kidMode: state.kidMode,
830
+ };
831
+ },
832
+ }),
833
+
834
+ action_roll: gameTool({
835
+ description:
836
+ "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.",
837
+ parameters: z.object({
838
+ move: z.enum(MOVES).describe("Which move the player is making"),
839
+ stat: z.enum(["edge","heart","iron","shadow","wits"]).describe("Which stat to roll"),
840
+ position: z.enum(["controlled","risky","desperate"]).describe("How dangerous the situation is"),
841
+ effect: z.enum(["limited","standard","great"]).describe("What can realistically be achieved"),
842
+ purpose: z.string().describe("What the character is attempting"),
843
+ targetNpcId: z.string().optional().describe("Target NPC id for social moves"),
844
+ }),
845
+ execute: ({ move, stat, position, effect, purpose, targetNpcId }, ctx) => {
846
+ const state = ctx.state;
847
+ const statValue = state[stat as keyof GameState] as number;
848
+ const roll = rollAction(stat, statValue, move);
849
+
850
+ // Apply consequences
851
+ const { consequences, clockEvents } = applyConsequences(
852
+ state, roll, position, effect, targetNpcId || null,
853
+ );
854
+
855
+ // Update chaos factor
856
+ updateChaosFactor(state, roll.result);
857
+
858
+ // Check for chaos interrupt
859
+ const interrupt = checkChaosInterrupt(state);
860
+
861
+ // Increment scene count
862
+ state.sceneCount++;
863
+
864
+ // Can burn momentum?
865
+ const burnTarget = canBurnMomentum(state, roll);
866
+
867
+ // Result labels
868
+ const resultLabels: Record<string, string> = {
869
+ STRONG_HIT: "Strong Hit", WEAK_HIT: "Weak Hit", MISS: "Miss",
870
+ };
871
+
872
+ return {
873
+ purpose,
874
+ move: MOVE_LABELS[move] || move,
875
+ moveCode: move,
876
+ stat,
877
+ statValue,
878
+ actionDice: [roll.d1, roll.d2],
879
+ challengeDice: [roll.c1, roll.c2],
880
+ actionScore: roll.actionScore,
881
+ result: resultLabels[roll.result],
882
+ resultCode: roll.result,
883
+ match: roll.match,
884
+ matchNote: roll.match
885
+ ? (roll.result === "STRONG_HIT" || roll.result === "WEAK_HIT"
886
+ ? "Fateful roll. Both challenge dice match. An unexpected advantage or twist."
887
+ : "Fateful roll. Both challenge dice match. A dire and dramatic escalation.")
888
+ : undefined,
889
+ position,
890
+ effect,
891
+ consequences,
892
+ clockEvents,
893
+ chaosInterrupt: interrupt,
894
+ currentHealth: state.health,
895
+ currentSpirit: state.spirit,
896
+ currentSupply: state.supply,
897
+ currentMomentum: state.momentum,
898
+ chaosFactor: state.chaosFactor,
899
+ crisisMode: state.crisisMode,
900
+ gameOver: state.gameOver,
901
+ sceneCount: state.sceneCount,
902
+ canBurnMomentum: !!burnTarget,
903
+ burnWouldYield: burnTarget ? resultLabels[burnTarget] : undefined,
904
+ };
905
+ },
906
+ }),
907
+
908
+ burn_momentum: gameTool({
909
+ description:
910
+ "Burn momentum to upgrade a roll result. Only valid when current momentum beats both challenge dice for the roll being upgraded. Resets momentum to +2.",
911
+ parameters: z.object({
912
+ c1: z.number().describe("First challenge die from the roll"),
913
+ c2: z.number().describe("Second challenge die from the roll"),
914
+ }),
915
+ execute: ({ c1, c2 }, ctx) => {
916
+ const state = ctx.state;
917
+ const mom = state.momentum;
918
+ if (mom <= 0) return { error: "Momentum is 0 or negative. Cannot burn." };
919
+
920
+ let newResult: string;
921
+ if (mom > c1 && mom > c2) newResult = "STRONG_HIT";
922
+ else if (mom > c1 || mom > c2) newResult = "WEAK_HIT";
923
+ else return { error: "Momentum not high enough to improve the result." };
924
+
925
+ const previousMomentum = mom;
926
+ state.momentum = 2; // Reset to starting value
927
+
928
+ const labels: Record<string, string> = {
929
+ STRONG_HIT: "Strong Hit", WEAK_HIT: "Weak Hit",
930
+ };
931
+
932
+ return {
933
+ burned: true,
934
+ previousMomentum,
935
+ newMomentum: 2,
936
+ newResult: labels[newResult],
937
+ newResultCode: newResult,
938
+ challengeDice: [c1, c2],
939
+ };
940
+ },
941
+ }),
942
+
943
+ oracle: gameTool({
944
+ description:
945
+ "Consult the oracle for narrative inspiration. Generates random prompts from thematic tables.",
946
+ parameters: z.object({
947
+ type: z.enum(["action_theme","npc_reaction","scene_twist","yes_no","chaos_check"]).describe("Type of oracle consultation"),
948
+ }),
949
+ execute: ({ type }, ctx) => {
950
+ const state = ctx.state;
951
+
952
+ if (type === "yes_no") {
953
+ const roll = d(6);
954
+ const answer = roll <= 2 ? "No" : roll <= 4 ? "Yes, but with a complication" : "Yes";
955
+ return { type: "yes_no", roll, answer };
956
+ }
957
+
958
+ if (type === "chaos_check") {
959
+ const interrupt = checkChaosInterrupt(state);
960
+ return {
961
+ type: "chaos_check",
962
+ chaosFactor: state.chaosFactor,
963
+ interrupted: !!interrupt,
964
+ interruptType: interrupt,
965
+ };
966
+ }
967
+
968
+ if (type === "npc_reaction") {
969
+ const reactions = [
970
+ "Acts on their agenda","Reveals a secret","Makes a demand",
971
+ "Offers unexpected help","Betrays expectations","Shows vulnerability",
972
+ "Escalates the conflict","Withdraws or retreats","Changes their stance",
973
+ "Introduces a new complication",
974
+ ];
975
+ return { type: "npc_reaction", reaction: pick(reactions) };
976
+ }
977
+
978
+ if (type === "scene_twist") {
979
+ const twists = [
980
+ "A hidden connection is revealed","The environment shifts dramatically",
981
+ "An NPC's true motives surface","Time pressure intensifies",
982
+ "An old enemy reappears","A resource is discovered or lost",
983
+ "The rules of the world bend","An alliance fractures",
984
+ "A prophecy or omen manifests","The stakes escalate unexpectedly",
985
+ ];
986
+ return { type: "scene_twist", twist: pick(twists) };
987
+ }
988
+
989
+ // action_theme (default)
990
+ const actions = [
991
+ "Abandon","Advance","Assault","Betray","Block","Bolster","Breach",
992
+ "Capture","Challenge","Change","Clash","Command","Compel","Conceal",
993
+ "Confront","Control","Corrupt","Create","Deceive","Defend","Defy",
994
+ "Deliver","Demand","Depart","Destroy","Distract","Endure","Escape",
995
+ "Explore","Falter","Find","Follow","Forge","Forsake","Gather","Guard",
996
+ "Guide","Harm","Hide","Hold","Hunt","Investigate","Journey","Learn",
997
+ "Leave","Locate","Lose","Manipulate","Move","Oppose","Overwhelm",
998
+ "Persevere","Plunder","Preserve","Protect","Rage","Reach","Reclaim",
999
+ "Refuse","Reject","Release","Repair","Resist","Restore","Reveal",
1000
+ "Risk","Salvage","Scheme","Search","Secure","Seize","Serve","Share",
1001
+ "Shatter","Shelter","Strengthen","Summon","Surrender","Surround",
1002
+ "Survive","Swear","Threaten","Track","Transform","Trap","Traverse",
1003
+ "Uncover","Uphold","Weaken","Withdraw",
1004
+ ];
1005
+ const themes = [
1006
+ "Ancestor","Ash","Beast","Blood","Bone","Burden","Communion",
1007
+ "Corruption","Crown","Darkness","Death","Debt","Decay","Despair",
1008
+ "Divinity","Doom","Dream","Dynasty","Eclipse","Exile","Faith","Fate",
1009
+ "Flesh","Fury","Grace","Grief","Guilt","Heritage","Hollow","Honor",
1010
+ "Horror","Hunger","Iron","Judgment","Kingdom","Knowledge","Legacy",
1011
+ "Loss","Madness","Memory","Mercy","Monster","Mystery","Night","Oath",
1012
+ "Omen","Order","Passage","Peril","Plague","Power","Pride","Prophecy",
1013
+ "Rebirth","Relic","Rot","Ruin","Sacrifice","Scar","Secret","Shadow",
1014
+ "Shard","Silence","Sorrow","Spirit","Splendor","Storm","Throne",
1015
+ "Time","Treachery","Truth","Valor","Vengeance","War","Waste","Winter",
1016
+ "Wisdom","Wound",
1017
+ ];
1018
+ return {
1019
+ type: "action_theme",
1020
+ action: pick(actions),
1021
+ theme: pick(themes),
1022
+ seed: creativitySeed(),
1023
+ };
1024
+ },
1025
+ }),
1026
+
1027
+ update_state: gameTool({
1028
+ description:
1029
+ "Lightweight state sync for during gameplay. Handles location changes, NPC additions, clock additions, time changes, and session log entries. Resource changes (health/spirit/supply/momentum) are auto-applied by action_roll — only use those fields here for manual adjustments like resting or trading. Pass only what changed.",
1030
+ parameters: z.object({
1031
+ // Location & time
1032
+ location: z.string().optional().describe("New location name"),
1033
+ locationDesc: z.string().optional().describe("Short location description"),
1034
+ timeOfDay: z.string().optional().describe("New time of day"),
1035
+ // Manual resource adjustments (resting, trading, etc.)
1036
+ health: z.number().optional(),
1037
+ spirit: z.number().optional(),
1038
+ supply: z.number().optional(),
1039
+ momentum: z.number().optional(),
1040
+ // Add a new NPC (name + description + disposition + agenda)
1041
+ addNpcName: z.string().optional().describe("New NPC name"),
1042
+ addNpcDesc: z.string().optional().describe("New NPC one-line description"),
1043
+ addNpcDisposition: z.enum(DISPOSITIONS).optional().describe("New NPC disposition"),
1044
+ addNpcAgenda: z.string().optional().describe("New NPC agenda"),
1045
+ // Update existing NPC
1046
+ updateNpcId: z.string().optional().describe("NPC id to update"),
1047
+ updateNpcDisposition: z.enum(DISPOSITIONS).optional(),
1048
+ updateNpcBond: z.number().optional(),
1049
+ updateNpcStatus: z.enum(["active","background","deceased"]).optional(),
1050
+ // Add a new clock
1051
+ addClockName: z.string().optional().describe("New clock name"),
1052
+ addClockType: z.enum(["threat","progress","scheme"]).optional(),
1053
+ addClockSegments: z.number().optional().describe("Number of segments, default 6"),
1054
+ addClockTrigger: z.string().optional().describe("What happens when clock fills"),
1055
+ // Advance or remove clock
1056
+ advanceClockName: z.string().optional().describe("Clock name to advance by 1"),
1057
+ removeClockName: z.string().optional().describe("Clock name to remove"),
1058
+ // Story arc
1059
+ advanceAct: z.boolean().optional().describe("Move to next story act"),
1060
+ storyComplete: z.boolean().optional().describe("Mark story as complete"),
1061
+ // Session log
1062
+ logEntry: z.string().optional().describe("Short log entry for this scene"),
1063
+ }),
1064
+ execute: (args, ctx) => {
1065
+ const state = ctx.state;
1066
+
1067
+ // Resources
1068
+ if (args.health !== undefined) state.health = Math.max(0, Math.min(5, args.health));
1069
+ if (args.spirit !== undefined) state.spirit = Math.max(0, Math.min(5, args.spirit));
1070
+ if (args.supply !== undefined) state.supply = Math.max(0, Math.min(5, args.supply));
1071
+ if (args.momentum !== undefined) state.momentum = Math.max(-6, Math.min(state.maxMomentum, args.momentum));
1072
+
1073
+ // Location
1074
+ if (args.location !== undefined) {
1075
+ if (state.currentLocation && state.currentLocation !== args.location) {
1076
+ state.locationHistory.push(state.currentLocation);
1077
+ if (state.locationHistory.length > 5) state.locationHistory = state.locationHistory.slice(-5);
1078
+ }
1079
+ state.currentLocation = args.location;
1080
+ }
1081
+ if (args.locationDesc !== undefined) state.currentSceneContext = args.locationDesc;
1082
+ if (args.timeOfDay !== undefined) state.timeOfDay = args.timeOfDay;
1083
+
1084
+ // Add NPC
1085
+ if (args.addNpcName) {
1086
+ const id = nextNpcId(state.npcs);
1087
+ state.npcs.push({
1088
+ id, name: args.addNpcName,
1089
+ description: args.addNpcDesc || "",
1090
+ disposition: args.addNpcDisposition || "neutral",
1091
+ bond: (args.addNpcDisposition === "friendly") ? 1 : (args.addNpcDisposition === "loyal") ? 2 : 0,
1092
+ agenda: args.addNpcAgenda || "", instinct: "",
1093
+ status: "active", aliases: [], lastMentionScene: state.sceneCount,
1094
+ });
1095
+ }
1096
+
1097
+ // Update NPC
1098
+ if (args.updateNpcId) {
1099
+ const npc = state.npcs.find(n => n.id === args.updateNpcId);
1100
+ if (npc) {
1101
+ if (args.updateNpcDisposition !== undefined) npc.disposition = args.updateNpcDisposition;
1102
+ if (args.updateNpcBond !== undefined) npc.bond = args.updateNpcBond;
1103
+ if (args.updateNpcStatus !== undefined) npc.status = args.updateNpcStatus;
1104
+ npc.lastMentionScene = state.sceneCount;
1105
+ }
1106
+ }
1107
+
1108
+ // Add clock
1109
+ if (args.addClockName) {
1110
+ state.clocks.push({
1111
+ id: `clock_${state.clocks.length + 1}`,
1112
+ name: args.addClockName,
1113
+ clockType: args.addClockType || "threat",
1114
+ segments: args.addClockSegments || 6,
1115
+ filled: 0,
1116
+ triggerDescription: args.addClockTrigger || "",
1117
+ owner: "world",
1118
+ });
1119
+ }
1120
+
1121
+ // Advance clock
1122
+ if (args.advanceClockName) {
1123
+ const clock = state.clocks.find(c => c.name === args.advanceClockName);
1124
+ if (clock) clock.filled = Math.min(clock.segments, clock.filled + 1);
1125
+ }
1126
+
1127
+ // Remove clock
1128
+ if (args.removeClockName) {
1129
+ state.clocks = state.clocks.filter(c => c.name !== args.removeClockName);
1130
+ }
1131
+
1132
+ // Story arc
1133
+ if (args.advanceAct && state.storyBlueprint) {
1134
+ state.storyBlueprint.currentAct = Math.min(
1135
+ state.storyBlueprint.acts.length,
1136
+ state.storyBlueprint.currentAct + 1,
1137
+ );
1138
+ }
1139
+ if (args.storyComplete && state.storyBlueprint) {
1140
+ state.storyBlueprint.storyComplete = true;
1141
+ }
1142
+
1143
+ // Session log
1144
+ if (args.logEntry) {
1145
+ state.sessionLog.push({
1146
+ scene: state.sceneCount,
1147
+ summary: args.logEntry,
1148
+ location: state.currentLocation,
1149
+ });
1150
+ if (state.sessionLog.length > MAX_SESSION_LOG) {
1151
+ state.sessionLog = state.sessionLog.slice(-MAX_SESSION_LOG);
1152
+ }
1153
+ }
1154
+
1155
+ // Crisis check
1156
+ if (state.health <= 0 && state.spirit <= 0) {
1157
+ state.gameOver = true; state.crisisMode = true;
1158
+ } else if (state.health <= 0 || state.spirit <= 0) {
1159
+ state.crisisMode = true;
1160
+ } else {
1161
+ state.crisisMode = false;
1162
+ }
1163
+
1164
+ return {
1165
+ success: true,
1166
+ initialized: state.initialized,
1167
+ phase: state.phase,
1168
+ settingGenre: state.settingGenre,
1169
+ settingTone: state.settingTone,
1170
+ settingArchetype: state.settingArchetype,
1171
+ settingDescription: state.settingDescription,
1172
+ playerName: state.playerName,
1173
+ characterConcept: state.characterConcept,
1174
+ edge: state.edge, heart: state.heart, iron: state.iron,
1175
+ shadow: state.shadow, wits: state.wits,
1176
+ health: state.health, spirit: state.spirit, supply: state.supply,
1177
+ momentum: state.momentum, maxMomentum: state.maxMomentum,
1178
+ currentLocation: state.currentLocation,
1179
+ currentSceneContext: state.currentSceneContext,
1180
+ timeOfDay: state.timeOfDay,
1181
+ chaosFactor: state.chaosFactor,
1182
+ crisisMode: state.crisisMode,
1183
+ gameOver: state.gameOver,
1184
+ sceneCount: state.sceneCount,
1185
+ npcs: state.npcs.map(n => ({
1186
+ id: n.id, name: n.name, disposition: n.disposition,
1187
+ bond: n.bond, agenda: n.agenda, status: n.status,
1188
+ description: n.description,
1189
+ })),
1190
+ clocks: state.clocks.map(c => ({
1191
+ id: c.id, name: c.name, clockType: c.clockType,
1192
+ segments: c.segments, filled: c.filled,
1193
+ triggerDescription: c.triggerDescription,
1194
+ })),
1195
+ storyBlueprint: state.storyBlueprint ? {
1196
+ structureType: state.storyBlueprint.structureType,
1197
+ currentAct: state.storyBlueprint.currentAct,
1198
+ totalActs: state.storyBlueprint.acts.length,
1199
+ centralConflict: state.storyBlueprint.centralConflict,
1200
+ thematicThread: state.storyBlueprint.thematicThread,
1201
+ storyComplete: state.storyBlueprint.storyComplete,
1202
+ currentPhase: state.storyBlueprint.acts[state.storyBlueprint.currentAct - 1]?.phase,
1203
+ } : null,
1204
+ kidMode: state.kidMode,
1205
+ sessionLog: state.sessionLog.slice(-5),
1206
+ };
1207
+ },
1208
+ }),
1209
+
1210
+ save_game: gameTool({
1211
+ description: "Save current game to persistent storage.",
1212
+ parameters: z.object({
1213
+ slot: z.string().optional().describe("Save slot name, defaults to autosave"),
1214
+ }),
1215
+ execute: async (args, ctx) => {
1216
+ const state = ctx.state;
1217
+ await ctx.kv.set(`save:${args.slot || "autosave"}`, state);
1218
+ return { saved: true, slot: args.slot || "autosave", name: state.playerName, scene: state.sceneCount };
1219
+ },
1220
+ }),
1221
+
1222
+ load_game: gameTool({
1223
+ description: "Load a previously saved game.",
1224
+ parameters: z.object({
1225
+ slot: z.string().optional().describe("Save slot name, defaults to autosave"),
1226
+ }),
1227
+ execute: async (args, ctx) => {
1228
+ const saved = await ctx.kv.get<GameState>(`save:${args.slot || "autosave"}`);
1229
+ if (!saved) return { error: "No save found." };
1230
+ Object.assign(ctx.state, saved);
1231
+ const state = ctx.state;
1232
+ return {
1233
+ loaded: true,
1234
+ playerName: state.playerName,
1235
+ characterConcept: state.characterConcept,
1236
+ settingGenre: state.settingGenre,
1237
+ sceneCount: state.sceneCount,
1238
+ currentLocation: state.currentLocation,
1239
+ initialized: state.initialized,
1240
+ };
1241
+ },
1242
+ }),
1243
+ },
1244
+ });