// Dynamic scenario generation — the two LLM steps (v0.31, #52). // // This is the additive "Create scenario" path. Two plain `chat()` calls (no // tools, no separate advisor endpoint — the advisor is embodied in step 1's // checkpoint list): // // 1. `generateDraft` — from an era template + a free-text description + a // year, the model designs a draft {@link WorldDraft} (nations with ISO_A3 // territory, stats, relations, optional backstory) AND an opening // checkpoint list (the advisor's briefing of the world's live tensions). // // 2. `finalizeWorld` — on commit, the model is re-asked to produce the // final world, constrained to the player's APPROVED checkpoints (and, // when the client re-sends the step-1 draft, based on it so the player's // chosen nation survives). The result is converted to a playable // {@link GameState} (region-key territory via the injected `expand` // resolver, per-region stats seeded) and returned for the caller to // persist — a normal game at `#/game/`. // // Both steps are defensive like the rest of the engine: raw model output is // fence-stripped, the first balanced object is extracted, JSON.parsed, and // validated via the pure `validateWorldDraft` (which never throws and returns // `null` for unusable input). A transport-level failure (the model being down) // throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to // a null world, never a crash. The `LlmClient` is injected so unit tests and // the deterministic scenario stub (server/stubScenario.ts) can replace the // network call exactly like the turn path does. import type { LlmClient } from './llm'; import { DEFAULT_MAX_TOKENS } from './llm'; import type { EraTemplate, WorldDraft } from '../shared/scenario'; import { validateWorldDraft } from '../shared/scenario'; import type { GameState } from '../shared/types'; import { worldDraftToGameState } from '../shared/scenario'; // --- Defensive parsing (shared with the engine's approach) -------------------- /** The system-prompt markers the deterministic scenario stub keys off. */ export const SCENARIO_GENERATE_MARKER = '<>'; export const SCENARIO_REGENERATE_MARKER = '<>'; /** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */ function stripFence(text: string): string { let t = text.trim(); const lead = t.match(/^```[a-zA-Z]*\s*\n?/); if (lead) t = t.slice(lead[0].length); const tail = t.match(/\n?\s*```\s*$/); if (tail) t = t.slice(0, t.length - tail[0].length); return t.trim(); } /** Extract the first balanced `{ ... }` substring (string-aware). */ function extractBalancedObject(text: string): string | null { const start = text.indexOf('{'); if (start === -1) return null; let depth = 0; let inStr = false; let esc = false; for (let i = start; i < text.length; i++) { const c = text[i]; if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === '{') depth++; else if (c === '}' && --depth === 0) return text.slice(start, i + 1); } return null; } /** Parse a raw top-level JSON object, or `null` when none is recoverable. */ function parseObject(raw: string): Record | null { const jsonText = extractBalancedObject(stripFence(raw)); if (!jsonText) return null; try { const obj: unknown = JSON.parse(jsonText); return obj && typeof obj === 'object' && !Array.isArray(obj) ? (obj as Record) : null; } catch { return null; } } // --- Prompt building ------------------------------------------------------------ /** * The shared nation JSON contract text, embedded in both system prompts so the * model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a * valid JSON object" style. */ const WORLD_SHAPE = [ '{', ' "world": {', ' "nations": [', ' {', ' "id": "short-stable-id",', ' "name": "Human readable name",', ' "type": "major|minor|power|alliance|faction",', ' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},', ' "territory": ["ISO_A3","ISO_A3"],', ' "relations": {"": -100..100}', ' }', ' ],', ' "events": [', ' {"nation":"","type":"","title":"short headline","text":"1-3 sentences","outcome":""}', ' ]', ' },', ' "checkpoints": ["short advisor checkpoint", "..."]', '}', ].join('\n'); /** * The system prompt for step 1 (generate). Tells the model to design a fresh * world for the era AND to emit an opening advisor checkpoint list. */ export function buildGeneratePrompt( template: EraTemplate, description: string, year: number, validCountryCodes: ReadonlySet | undefined, ): { system: string; user: string } { const codes = validCountryCodes ? [...validCountryCodes].sort() : null; const system = [ `${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`, 'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.', 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:', WORLD_SHAPE, '', 'RULES', '- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).', codes ? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' + codes.join(', ') + '. A nation may control several codes; together the nations should cover a plausible portion of the world.' : '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).', '- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.', '- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.', '- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.', '- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.', ].join('\n'); const user = JSON.stringify( { era: { id: template.id, name: template.name, year, description: template.eraDescription, }, suggestedPowers: template.suggestedPowers, tensions: template.tensions, description, year, ...(codes ? { validCountryCodes: codes } : {}), }, null, 2, ); return { system, user }; } /** * The system prompt for step 2 (regenerate/finalize). Same world contract, but * the model must now respect the player's APPROVED checkpoints (and, when a * draft is supplied, stay close to it so the player's chosen nation survives). */ export function buildRegeneratePrompt( template: EraTemplate, description: string, year: number, approvedCheckpoints: string[], validCountryCodes: ReadonlySet | undefined, draft?: WorldDraft, ): { system: string; user: string } { const codes = validCountryCodes ? [...validCountryCodes].sort() : null; const system = [ `${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`, 'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.', 'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:', [ '{', ' "world": {', ' "nations": [', ' {', ' "id": "short-stable-id",', ' "name": "Human readable name",', ' "type": "major|minor|power|alliance|faction",', ' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},', ' "territory": ["ISO_A3","ISO_A3"],', ' "relations": {"": -100..100}', ' }', ' ],', ' "events": [', ' {"nation":"","type":"","title":"short headline","text":"1-3 sentences","outcome":""}', ' ]', ' }', '}', ].join('\n'), '', 'RULES', '- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.', '- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.', codes ? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' + codes.join(', ') + '.' : '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).', '- Keep every stat value between 0 and 100.', ].join('\n'); const user = JSON.stringify( { era: { id: template.id, name: template.name, year, description: template.eraDescription, }, description, year, approvedCheckpoints, ...(draft ? { draft } : {}), ...(codes ? { validCountryCodes: codes } : {}), }, null, 2, ); return { system, user }; } // --- Parsing the model's answers ---------------------------------------------- /** Parse the step-1 (generate) answer into a validated world + checkpoint list. */ export function parseGenerate( raw: string, validCountryCodes?: ReadonlySet, ): { world: WorldDraft | null; checkpoints: string[] } { const obj = parseObject(raw); if (!obj) return { world: null, checkpoints: [] }; const world = validateWorldDraft(obj.world, { validCountryCodes }); const checkpoints: string[] = []; if (Array.isArray(obj.checkpoints)) { const seen = new Set(); for (const c of obj.checkpoints) { if (typeof c !== 'string') continue; const t = c.trim(); if (t.length > 2 && !seen.has(t)) { seen.add(t); checkpoints.push(t); } } } return { world, checkpoints: checkpoints.slice(0, 12) }; } /** Parse the step-2 (finalize) answer into a validated world (or null). */ export function parseRegenerate( raw: string, validCountryCodes?: ReadonlySet, ): WorldDraft | null { const obj = parseObject(raw); if (!obj) return null; return validateWorldDraft(obj.world, { validCountryCodes }); } // --- Orchestration ------------------------------------------------------------- export interface GenerateDraftOptions { llm: LlmClient; template: EraTemplate; description: string; year: number; /** ISO_A3 codes to accept (undefined = no filtering). */ validCountryCodes?: ReadonlySet; } /** * Step 1: ask the model to design a draft world + an advisor checkpoint list. * Throws only on a transport-level failure (the model is down) — a bad answer * yields `{ world: null, checkpoints: [] }` and is the caller's to surface as * an error, never a crash. */ export async function generateDraft( opts: GenerateDraftOptions, ): Promise<{ world: WorldDraft | null; checkpoints: string[] }> { const { llm, template, description, year, validCountryCodes } = opts; const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.'; const out = await llm.chat( [{ role: 'system', content: system }, { role: 'user', content: user }], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS }, ); const parsed = parseGenerate(out, validCountryCodes); if (parsed.world) return parsed; // One nudge re-send with a fresh history, mirroring the engine's retry. const retry = await llm.chat( [ { role: 'system', content: system }, { role: 'user', content: user }, { role: 'assistant', content: '' }, { role: 'user', content: nudge }, ], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS }, ); return parseGenerate(retry, validCountryCodes); } export interface FinalizeWorldOptions { llm: LlmClient; template: EraTemplate; description: string; year: number; approvedCheckpoints: string[]; /** The step-1 draft (re-sent by the client) — the base the final world refines. */ draft?: WorldDraft; /** The nation id the player will lead (kept in the final world when present). */ playerNationId: string; /** ISO_A3 codes to accept (undefined = no filtering). */ validCountryCodes?: ReadonlySet; /** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */ expand: (countries: string[]) => string[]; /** The new game's id (the route sets randomUUID()). */ id?: string; /** The scenario title (becomes presetTitle; default 'Custom World'). */ title?: string; /** The game's start date (YYYY-MM-DD, from the chosen year). */ startDate?: string; } /** * Step 2: ask the model to produce the final world constrained to the approved * checkpoints, convert it to a playable {@link GameState}, and return it for the * caller to persist. Throws only on a transport-level failure; a bad answer * that still yields a usable world is committed, and if the final world drops * the player's chosen nation the conversion falls back to the first nation so * the game is always playable. */ export async function finalizeWorld(opts: FinalizeWorldOptions): Promise { const { llm, template, description, year, approvedCheckpoints, draft, playerNationId, validCountryCodes, expand, id, title, startDate, } = opts; const { system, user } = buildRegeneratePrompt( template, description, year, approvedCheckpoints, validCountryCodes, draft, ); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.'; const out = await llm.chat( [{ role: 'system', content: system }, { role: 'user', content: user }], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }, ); let world = parseRegenerate(out, validCountryCodes); if (!world) { const retry = await llm.chat( [ { role: 'system', content: system }, { role: 'user', content: user }, { role: 'assistant', content: '' }, { role: 'user', content: nudge }, ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }, ); world = parseRegenerate(retry, validCountryCodes); } if (!world) { // The model could not produce a usable final world. Rather than fail the // whole commit, fall back to the draft the player already approved (if any) // so the commit still yields a playable game. if (draft) world = draft; else throw new Error('finalize: the model returned no usable world'); } return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate }); }