diff --git a/server/regions.ts b/server/regions.ts index 4f7a800..27ec3cd 100644 --- a/server/regions.ts +++ b/server/regions.ts @@ -259,6 +259,18 @@ export function validKeys(): ReadonlySet { return data ? data.validKeys : new Set(); } +/** + * The set of ISO_A3 country codes that own at least one sub-region in the + * manifest (the valid `territory` codes for a world draft, v0.31 #52). These + * are the keys of `keysByCountry` — i.e. the distinct `properties.parent` + * values in regions.geo.json. Empty when no region data is loaded; callers + * pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a + * draft's territory is checked against real countries. + */ +export function validCountryCodes(): ReadonlySet { + return data ? new Set(Object.keys(data.keysByCountry)) : new Set(); +} + /** * True when the region-adjacency manifest loaded with at least one entry. * Callers (the server's jump route) use this to decide whether to hand the diff --git a/server/scenario.test.ts b/server/scenario.test.ts new file mode 100644 index 0000000..76507e5 --- /dev/null +++ b/server/scenario.test.ts @@ -0,0 +1,335 @@ +// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52): +// the prompt builders, the defensive parsers, generateDraft / finalizeWorld +// over an injected LlmClient, and the deterministic scenario stub. +import { describe, expect, it } from 'vitest'; +import { + buildGeneratePrompt, + buildRegeneratePrompt, + finalizeWorld, + generateDraft, + parseGenerate, + parseRegenerate, + SCENARIO_GENERATE_MARKER, + SCENARIO_REGENERATE_MARKER, +} from './scenario'; +import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario'; +import type { EraTemplate, WorldDraft } from '../shared/scenario'; +import type { ChatMessage, LlmClient } from './llm'; + +const TEMPLATE: EraTemplate = { + id: 'near-future', + name: 'Near Future', + year: 2035, + eraDescription: 'A fractured, multipolar 2035.', + suggestedPowers: [ + { name: 'United States', region: 'USA', note: 'declining hegemon' }, + { name: 'China', region: 'CHN', note: 'rising industrial power' }, + ], + tensions: ['Indo-Pacific standoff', 'Climate migration'], +}; + +const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']); + +/** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */ +function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient { + return { + async chat(messages) { + if (typeof canned === 'function') return canned(messages); + return canned ?? '{"world":{"nations":[]},"checkpoints":[]}'; + }, + async listModels() { + return []; + }, + }; +} + +const GOOD_GENERATE = JSON.stringify({ + world: { + nations: [ + { + id: 'alba', + name: 'Alba', + type: 'major', + stats: { military: 70, economy: 60, stability: 65, population: 55 }, + territory: ['FRA'], + relations: { boreas: -60 }, + }, + { + id: 'boreas', + name: 'Boreas', + type: 'major', + stats: { military: 55, economy: 70, stability: 50, population: 60 }, + territory: ['RUS'], + relations: { alba: -60 }, + }, + ], + }, + checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'], +}); + +const GOOD_REGENERATE = JSON.stringify({ + world: { + nations: [ + { + id: 'alba', + name: 'Alba', + type: 'major', + stats: { military: 70, economy: 60, stability: 70, population: 55 }, + territory: ['FRA'], + relations: { boreas: -70 }, + }, + { + id: 'boreas', + name: 'Boreas', + type: 'major', + stats: { military: 55, economy: 70, stability: 55, population: 60 }, + territory: ['RUS'], + relations: { alba: -70 }, + }, + ], + }, +}); + +describe('buildGeneratePrompt', () => { + it('embeds the era, description, year and the valid country codes', () => { + const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES); + expect(system).toContain(SCENARIO_GENERATE_MARKER); + expect(system).toContain('"checkpoints"'); + expect(user).toContain('near-future'); + expect(user).toContain('a cold war in space'); + expect(user).toContain('2041'); + expect(user).toContain('CHN'); + expect(user).toContain('validCountryCodes'); + }); + + it('omits the code list when no valid codes are supplied', () => { + const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined); + expect(system).toContain('standard ISO 3166-1 alpha-3'); + expect(user).not.toContain('validCountryCodes'); + }); +}); + +describe('buildRegeneratePrompt', () => { + it('embeds the approved checkpoints and the draft when present', () => { + const draft: WorldDraft = { + nations: [ + { + id: 'alba', + name: 'Alba', + stats: { military: 70, economy: 60, stability: 65, population: 55 }, + territory: ['FRA'], + relations: {}, + }, + ], + }; + const { system, user } = buildRegeneratePrompt( + TEMPLATE, + 'desc', + 2035, + ['Alba presses its advantage.'], + VALID_CODES, + draft, + ); + expect(system).toContain(SCENARIO_REGENERATE_MARKER); + expect(user).toContain('approvedCheckpoints'); + expect(user).toContain('Alba presses its advantage.'); + expect(user).toContain('draft'); + expect(user).toContain('alba'); + }); + + it('omits the draft field when none is supplied', () => { + const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined); + expect(user).not.toContain('draft'); + }); +}); + +describe('parseGenerate / parseRegenerate (defensive)', () => { + it('parses a valid generate answer into a world + checkpoints', () => { + const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES); + expect(world).not.toBeNull(); + expect(world!.nations).toHaveLength(2); + expect(world!.nations[0].id).toBe('alba'); + expect(world!.nations[0].territory).toEqual(['FRA']); + expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']); + }); + + it('strips code fences and drops unknown country codes', () => { + const raw = '```json\n' + GOOD_GENERATE + '\n```'; + const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set + expect(world).not.toBeNull(); + expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]); + }); + + it('returns null world + empty checkpoints for garbage', () => { + const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES); + expect(world).toBeNull(); + expect(checkpoints).toEqual([]); + }); + + it('parses a valid regenerate answer (no checkpoints echoed)', () => { + const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES); + expect(world).not.toBeNull(); + expect(world!.nations[0].stats.stability).toBe(70); + }); +}); + +describe('generateDraft (injected client)', () => { + it('returns a validated world + checkpoints from the model', async () => { + const { world, checkpoints } = await generateDraft({ + llm: fakeClient(GOOD_GENERATE), + template: TEMPLATE, + description: 'a cold war in space', + year: 2041, + validCountryCodes: VALID_CODES, + }); + expect(world).not.toBeNull(); + expect(world!.nations).toHaveLength(2); + expect(checkpoints).toHaveLength(2); + }); + + it('nudges once when the first answer is unusable', async () => { + let call = 0; + const client: LlmClient = { + async chat() { + call += 1; + return call === 1 ? 'total garbage' : GOOD_GENERATE; + }, + async listModels() { + return []; + }, + }; + const { world, checkpoints } = await generateDraft({ + llm: client, + template: TEMPLATE, + description: 'x', + year: 2035, + validCountryCodes: VALID_CODES, + }); + expect(call).toBe(2); + expect(world).not.toBeNull(); + expect(checkpoints).toHaveLength(2); + }); + + it('returns null world when even the nudge fails', async () => { + const { world } = await generateDraft({ + llm: fakeClient('still no json'), + template: TEMPLATE, + description: 'x', + year: 2035, + validCountryCodes: VALID_CODES, + }); + expect(world).toBeNull(); + }); +}); + +describe('finalizeWorld (injected client)', () => { + const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]); + + it('converts the finalized world to a playable GameState', async () => { + const state = await finalizeWorld({ + llm: fakeClient(GOOD_REGENERATE), + template: TEMPLATE, + description: 'x', + year: 2035, + approvedCheckpoints: ['Alba presses its advantage.'], + playerNationId: 'alba', + validCountryCodes: VALID_CODES, + expand, + id: 'g-1', + title: 'Custom World', + startDate: '2035-01-01', + }); + expect(state.id).toBe('g-1'); + expect(state.presetId).toBe('dynamic'); + expect(state.playerNationId).toBe('alba'); + expect(state.turn).toBe(1); + expect(state.date).toBe('2035-01-01'); + // FRA expands to two region keys; the per-region stats are seeded. + expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']); + expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats); + }); + + it('falls back to the first nation when the model drops the player nation', async () => { + // The regenerated world has no 'alba' — only 'boreas'. + const dropped = JSON.stringify({ + world: { + nations: [ + { + id: 'boreas', + name: 'Boreas', + stats: { military: 55, economy: 70, stability: 55, population: 60 }, + territory: ['RUS'], + relations: {}, + }, + ], + }, + }); + const state = await finalizeWorld({ + llm: fakeClient(dropped), + template: TEMPLATE, + description: 'x', + year: 2035, + approvedCheckpoints: [], + playerNationId: 'alba', // not in the world + validCountryCodes: VALID_CODES, + expand, + }); + expect(state.playerNationId).toBe('boreas'); + }); +}); + +describe('scenario stub (deterministic, LLM_STUB=1)', () => { + it('is deterministic: same input → same generate output', async () => { + const llm = makeScenarioStubLlmClient(); + const a = await generateDraft({ + llm, + template: TEMPLATE, + description: 'd', + year: 2035, + validCountryCodes: VALID_CODES, + }); + const b = await generateDraft({ + llm, + template: TEMPLATE, + description: 'completely different description', + year: 1999, + validCountryCodes: VALID_CODES, + }); + expect(a.world).toEqual(b.world); + expect(a.checkpoints).toEqual(b.checkpoints); + expect(a.world!.nations).toHaveLength(3); + expect(a.checkpoints).toHaveLength(3); + }); + + it('finalize respects the approved checkpoints (deterministically)', async () => { + const baseline = stubWorldFor([]); + const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']); + const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0; + const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0; + const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0; + const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0; + // Approving a checkpoint that names Alba raises Alba's stability only. + expect(albaWith).toBe(albaBase + 5); + expect(boreasWith).toBe(boreasBase); + }); + + it('finalizeWorld over the stub returns a playable game honoring approvals', async () => { + const llm = makeScenarioStubLlmClient(); + const state = await finalizeWorld({ + llm, + template: TEMPLATE, + description: 'd', + year: 2035, + approvedCheckpoints: ['Boreas fortifies its northern frontier.'], + playerNationId: 'boreas', + validCountryCodes: VALID_CODES, + expand: (cs) => cs.flatMap((c) => [`${c}-a`]), + id: 'stub-1', + }); + expect(state.id).toBe('stub-1'); + expect(state.playerNationId).toBe('boreas'); + expect(state.nations.boreas.territory).toEqual(['RUS-a']); + // The approved Boreas checkpoint raised its stability by 5 (50 → 55). + expect(state.nations.boreas.stats.stability).toBe(55); + }); +}); diff --git a/server/scenario.ts b/server/scenario.ts new file mode 100644 index 0000000..16f64cc --- /dev/null +++ b/server/scenario.ts @@ -0,0 +1,390 @@ +// 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 }); +} diff --git a/server/stubScenario.ts b/server/stubScenario.ts new file mode 100644 index 0000000..0ff36d0 --- /dev/null +++ b/server/stubScenario.ts @@ -0,0 +1,122 @@ +// Deterministic SCENARIO-generation stub (v0.31, #52). +// +// Distinct from the turn stub (server/stub.ts, which stays untouched): this one +// answers the two scenario-generation LLM calls (see server/scenario.ts) so the +// whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and +// the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it +// plugs in as a Transport, and it discriminates the two calls by the markers +// scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the +// draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how +// the turn stub discriminates the advisor call via ADVISOR_MARKER. +// +// The stub returns a fixed 3-nation world and a fixed checkpoint list for the +// generate step, and — for the finalize step — the same world refined in a +// simple, deterministic way that depends on the approved checkpoints, so the +// "finalize respects approved checkpoints" behavior is observable end to end. +import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm'; +import { SCENARIO_REGENERATE_MARKER } from './scenario'; +import type { WorldDraft } from '../shared/scenario'; + +/** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */ +const WORLD: WorldDraft = { + nations: [ + { + id: 'alba', + name: 'Alba', + type: 'major', + stats: { military: 70, economy: 60, stability: 65, population: 55 }, + territory: ['FRA'], + relations: { boreas: -60, gamma: 20 }, + }, + { + id: 'boreas', + name: 'Boreas', + type: 'major', + stats: { military: 55, economy: 70, stability: 50, population: 60 }, + territory: ['RUS'], + relations: { alba: -60, gamma: 10 }, + }, + { + id: 'gamma', + name: 'Gamma', + type: 'minor', + stats: { military: 30, economy: 45, stability: 70, population: 40 }, + territory: ['ESP'], + relations: { alba: 20, boreas: 10 }, + }, + ], + events: [ + { + nation: 'alba', + type: 'political', + title: 'The new order takes shape', + text: 'The three powers settle their borders after the great realignment.', + outcome: 'neutral', + }, + ], +}; + +/** The advisor's opening checkpoint list (fixed, in order). */ +const CHECKPOINTS: string[] = [ + 'Alba dominates the western sea lanes and presses its advantage.', + 'Boreas fortifies its northern frontier against a long winter.', + 'Gamma brokers a neutral corridor between the two rivals.', +]; + +/** + * Deterministically refine the fixed world to "respect" the approved + * checkpoints: each approved checkpoint that names one of the fixed nations (by + * the first 4 letters of the nation's name, case-insensitive) raises that + * nation's stability by 5 (clamped at 100). Same input → same output, and the + * result visibly depends on which checkpoints were approved. + */ +export function stubWorldFor(approved: string[]): WorldDraft { + const nations = WORLD.nations.map((n) => ({ + ...n, + stats: { ...n.stats }, + relations: { ...n.relations }, + })); + const lower = approved.map((c) => c.toLowerCase()); + for (const n of nations) { + const key = n.name.toLowerCase().slice(0, 4); + if (lower.some((c) => c.includes(key))) { + n.stats.stability = Math.min(100, n.stats.stability + 5); + } + } + return { nations, events: WORLD.events }; +} + +export function makeScenarioStubTransport(): Transport { + return async (_url, init) => { + let content: string; + try { + const req = JSON.parse(String(init?.body ?? '{}')) as { messages?: ChatMessage[] }; + const sys = req.messages?.find((m) => m.role === 'system')?.content ?? ''; + const user = req.messages?.find((m) => m.role === 'user'); + if (sys.includes(SCENARIO_REGENERATE_MARKER)) { + // Finalize step: read the approved checkpoints out of the user payload + // and refine the fixed world accordingly (no checkpoints field echoed). + const payload = JSON.parse(user?.content ?? '{}') as { + approvedCheckpoints?: string[]; + }; + content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) }); + } else { + // Generate step (and any unrecognized prompt): the fixed world + list. + content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS }); + } + } catch { + content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS }); + } + return { + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content } }] }), + text: async () => content, + } as unknown as Response; + }; +} + +/** A ready-made LlmClient backed by the deterministic scenario transport. */ +export function makeScenarioStubLlmClient(): LlmClient { + return createLlmClient({ transport: makeScenarioStubTransport() }); +} diff --git a/shared/scenario.ts b/shared/scenario.ts index b20062c..062eabc 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -385,6 +385,17 @@ export function worldDraftToGameState( }; } + // Guard: a finalize step (or a bad model answer) might drop the nation the + // player chose to lead. Rather than produce a game with a dangling + // playerNationId, fall back to the first nation (deterministic insertion + // order) so the resulting game is always playable. + const effectivePlayer = + nations[playerNationId] !== undefined + ? playerNationId + : draft.nations.length > 0 + ? draft.nations[0].id + : ''; + // Seed the event feed from the draft's optional backstory (turn 1, startDate). const events: EventEntry[] = (draft.events ?? []).map((e) => makeEvent(e as unknown as TurnEventSpec, 1, startDate), @@ -394,7 +405,7 @@ export function worldDraftToGameState( id, presetId: 'dynamic', presetTitle: title, - playerNationId, + playerNationId: effectivePlayer, turn: 1, date: startDate, nations,