@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.
- package/LICENSE +21 -0
- package/dist/_build-p1HHkdon.mjs +132 -0
- package/dist/_discover-BzlCDVZ6.mjs +161 -0
- package/dist/_init-l_uoyFCN.mjs +82 -0
- package/dist/_link-BGXGFYWa.mjs +47 -0
- package/dist/_server-common-qLA1QU2C.mjs +36 -0
- package/dist/_ui-kJIua5L9.mjs +44 -0
- package/dist/cli.mjs +318 -0
- package/dist/deploy-KyNJaoP5.mjs +86 -0
- package/dist/dev-DBFvKyzk.mjs +39 -0
- package/dist/init-BWG5OrQa.mjs +65 -0
- package/dist/rag-BnCMnccf.mjs +173 -0
- package/dist/secret-CzeHIGzE.mjs +50 -0
- package/dist/start-C1qkhU4O.mjs +23 -0
- package/package.json +39 -0
- package/templates/_shared/.env.example +5 -0
- package/templates/_shared/CLAUDE.md +1051 -0
- package/templates/_shared/biome.json +32 -0
- package/templates/_shared/global.d.ts +1 -0
- package/templates/_shared/index.html +16 -0
- package/templates/_shared/package.json +23 -0
- package/templates/_shared/tsconfig.json +15 -0
- package/templates/code-interpreter/agent.ts +27 -0
- package/templates/code-interpreter/client.tsx +3 -0
- package/templates/css.d.ts +1 -0
- package/templates/dispatch-center/agent.ts +1227 -0
- package/templates/dispatch-center/client.tsx +505 -0
- package/templates/embedded-assets/agent.ts +48 -0
- package/templates/embedded-assets/client.tsx +3 -0
- package/templates/embedded-assets/knowledge.json +20 -0
- package/templates/health-assistant/agent.ts +160 -0
- package/templates/health-assistant/client.tsx +3 -0
- package/templates/infocom-adventure/agent.ts +164 -0
- package/templates/infocom-adventure/client.tsx +300 -0
- package/templates/math-buddy/agent.ts +21 -0
- package/templates/math-buddy/client.tsx +3 -0
- package/templates/memory-agent/agent.ts +20 -0
- package/templates/memory-agent/client.tsx +3 -0
- package/templates/night-owl/agent.ts +98 -0
- package/templates/night-owl/client.tsx +12 -0
- package/templates/personal-finance/agent.ts +26 -0
- package/templates/personal-finance/client.tsx +3 -0
- package/templates/pizza-ordering/agent.ts +218 -0
- package/templates/pizza-ordering/client.tsx +264 -0
- package/templates/simple/agent.ts +6 -0
- package/templates/simple/client.tsx +3 -0
- package/templates/smart-research/agent.ts +164 -0
- package/templates/smart-research/client.tsx +3 -0
- package/templates/solo-rpg/agent.ts +1244 -0
- package/templates/solo-rpg/client.tsx +698 -0
- package/templates/support/README.md +62 -0
- package/templates/support/agent.ts +19 -0
- package/templates/support/client.tsx +3 -0
- package/templates/travel-concierge/agent.ts +29 -0
- package/templates/travel-concierge/client.tsx +3 -0
- package/templates/tsconfig.json +1 -0
- package/templates/web-researcher/agent.ts +17 -0
- 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
|
+
});
|