Files
History/server/scenario.ts
T
kamilkrzywda 68402694c5 feat(#52): scenario generator LLM steps + deterministic stub
- server/scenario.ts: buildGeneratePrompt / buildRegeneratePrompt, defensive
  parseGenerate / parseRegenerate (fence-strip + balanced-object + validate),
  generateDraft (draft world + advisor checkpoints) and finalizeWorld (final
  world constrained to approved checkpoints -> playable GameState), all over
  the injectable LlmClient with a nudge re-send. SCENARIO_* markers for the
  stub.
- server/stubScenario.ts: deterministic scenario stub transport (fixed 3-nation
  world + checkpoint list; finalize refines stability per approved checkpoint)
  + makeScenarioStubLlmClient. Distinct from and never touching turn stub.ts.
- server/regions.ts: validCountryCodes() accessor (ISO_A3 parents) for draft
  territory validation.
- shared/scenario.ts: worldDraftToGameState now guards a dangling playerNationId
  (falls back to the first nation) so a finalize that drops it stays playable.
- server/scenario.test.ts: 16 tests (prompt builders, defensive parsing,
  generateDraft/finalizeWorld over a fake client + the stub).

Fixes #52
2026-10-15 09:46:34 +00:00

391 lines
16 KiB
TypeScript

// 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/<id>`.
//
// 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 = '<<SCENARIO_GENERATE>>';
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
/** 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<string, unknown> | 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<string, unknown>)
: 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": {"<other nation id or name>": -100..100}',
' }',
' ],',
' "events": [',
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
' ]',
' },',
' "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<string> | 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<string> | 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": {"<other nation id or name>": -100..100}',
' }',
' ],',
' "events": [',
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
' ]',
' }',
'}',
].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<string>,
): { 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<string>();
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<string>,
): 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<string>;
}
/**
* 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<string>;
/** 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<GameState> {
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 });
}