- 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
391 lines
16 KiB
TypeScript
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 });
|
|
}
|