@alexkroman1/aai 0.8.1 → 0.8.3

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