// The world-sim engine: pure, testable, no I/O. It (1) builds the LLM prompt from a // preset + state, (2) parses and strictly validates the LLM's TurnResult JSON // (defensively — never throws), (3) applies the delta atomically to a GameState, // and (4) builds the advisor brainstorm prompt. The network call itself lives in // llm.ts (injectable) and is invoked by runTurn. import type { EventOutcome, EventType, GameState, Preset, RelationStatus, RoundItem, StatKey, } from '../shared/types'; import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { addDays, clampStat, isDateLike, makeEvent, nationForPrompt, type TurnEventSpec, type TurnResult, } from '../shared/protocol'; import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm'; import { ADVISOR_MARKER } from './stub'; const VALID_EVENT_TYPES = new Set(EVENT_TYPES); const VALID_RELATIONS = new Set(RELATION_STATUSES); const VALID_OUTCOMES = new Set(['success', 'failure', 'partial', 'neutral']); const VALID_STATS = new Set(['military', 'economy', 'stability', 'population']); // --- Prompt building ---------------------------------------------------------- /** The LLM system prompt: world context + rules + a strict JSON contract. */ export function buildSystemPrompt(preset: Preset): string { const rules = preset.simulationRules .map((r) => `- ${r}`) .join('\n'); return [ `WORLD: ${preset.title} — ${preset.startDate}`, preset.worldBackground, '', 'SIMULATION RULES', rules, '', 'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.', 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:', '{', ' "date": "YYYY-MM-DD (a few days after the current date)",', ' "events": [', ' {"nation":"","type":"","title":"short headline","text":"1-3 sentences of narrative","outcome":""}', ' ],', ' "stateChanges": [{"nation":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', '}', '', 'HARD CONSTRAINTS', '- Use only nation ids and ISO_A3 codes that exist in the provided state.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep every stat value between 0 and 100.', '- Only change borders when war or a decisive political shift justifies it.', '- If a field is not relevant this turn, set it to an empty array.', ].join('\n'); } /** * The user message: a compact JSON snapshot of the world plus the player's * queued round (`items`) — the free-text actions and diplomatic messages to be * simulated this period. `playerActions` is an LLM-friendly rendering of each * item; the raw `pending` list is also emitted so the deterministic stub (and any * other consumer) can read the exact items. The framing instruction tells the * model to simulate the combined consequences of the whole batch (or to let the * world advance on its own when the batch is empty) and return one TurnResult. */ export function buildUserPrompt(state: GameState, items: RoundItem[]): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; const playerActions = items.map((i) => i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text }, ); const roundInstruction = items.length > 0 ? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify( playerActions, )}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.` : 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.'; const payload = { state: { turn: state.turn, date: state.date, playerNationId: state.playerNationId, nations: Object.fromEntries( Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]), ), recentEvents: state.events.slice(-5).map((e) => ({ turn: e.turn, date: e.date, nation: e.nation, type: e.type, title: e.title, text: e.text, outcome: e.outcome, })), }, playerActions, pending: items, roundInstruction, }; return JSON.stringify(payload, null, 2); } // --- Defensive parsing -------------------------------------------------------- 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 raw LLM output into a validated TurnResult, or null if unusable. Strips * code fences, extracts the first balanced object, JSON.parse, then validates. */ export function parseTurnResult(raw: string): TurnResult | null { const cleaned = stripFence(raw); const jsonText = extractBalancedObject(cleaned); if (!jsonText) return null; let obj: unknown; try { obj = JSON.parse(jsonText); } catch { return null; } return validateTurnResult(obj); } function str(s: unknown, max = 0): string | null { if (typeof s !== 'string') return null; const t = s.trim(); if (!t) return null; return max ? t.slice(0, max) : t; } /** * Validate and normalize an arbitrary parsed object into a safe TurnResult. * Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null * only when the input is not even a plain object. */ export function validateTurnResult(input: unknown): TurnResult | null { if (typeof input !== 'object' || input === null || Array.isArray(input)) return null; const o = input as Record; const result: TurnResult = { date: typeof o.date === 'string' ? o.date : '', events: [], stateChanges: [], relations: [], territoryChanges: [], }; if (Array.isArray(o.events)) { for (const e of o.events) { if (!e || typeof e !== 'object') continue; const ev = e as Record; const nation = str(ev.nation); const title = str(ev.title, 200); const text = str(ev.text, 1200); if (!nation || !title || !text) continue; const type = VALID_EVENT_TYPES.has(String(ev.type)) ? (ev.type as EventType) : 'political'; const outcome = VALID_OUTCOMES.has(String(ev.outcome)) ? (ev.outcome as EventOutcome) : 'neutral'; result.events.push({ nation, type, title, text, outcome }); } } if (Array.isArray(o.stateChanges)) { for (const c of o.stateChanges) { if (!c || typeof c !== 'object') continue; const cc = c as Record; const nation = str(cc.nation); const stat = str(cc.stat); if (!nation || !stat || !VALID_STATS.has(stat)) continue; if (typeof cc.value !== 'number' || !Number.isFinite(cc.value)) continue; if (cc.op === 'add') { result.stateChanges.push({ nation, stat: stat as StatKey, op: 'add', value: cc.value, }); } else if (cc.op === 'set') { result.stateChanges.push({ nation, stat: stat as StatKey, op: 'set', value: clampStat(cc.value), }); } } } if (Array.isArray(o.relations)) { for (const r of o.relations) { if (!r || typeof r !== 'object') continue; const rr = r as Record; const a = str(rr.a); const b = str(rr.b); if (!a || !b || a === b) continue; if (!VALID_RELATIONS.has(String(rr.set))) continue; result.relations.push({ a, b, set: rr.set as RelationStatus }); } } if (Array.isArray(o.territoryChanges)) { for (const t of o.territoryChanges) { if (!t || typeof t !== 'object') continue; const tt = t as Record; const nation = str(tt.nation); if (!nation) continue; const gain = Array.isArray(tt.gain) ? tt.gain.filter((x): x is string => typeof x === 'string') : []; const lose = Array.isArray(tt.lose) ? tt.lose.filter((x): x is string => typeof x === 'string') : []; if (gain.length === 0 && lose.length === 0) continue; result.territoryChanges.push({ nation, gain, lose }); } } return result; } /** A turn is "meaningful" if it produced at least an event or a stat change. */ export function isMeaningful(result: TurnResult): boolean { return result.events.length > 0 || result.stateChanges.length > 0; } // --- Applying the delta ------------------------------------------------------- function genericUnclearEvent(state: GameState): TurnEventSpec { return { nation: state.playerNationId, type: 'political', title: 'The turn was unclear', text: 'The simulation could not resolve a clear outcome this turn; the world drifts forward on its own momentum.', outcome: 'neutral', }; } /** * Apply a TurnResult to a GameState immutably: advance the date/turn, append * events, apply stat changes (clamped), symmetric relations, and territory moves. */ export function applyTurnResult(state: GameState, result: TurnResult): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; // every mutation is a new revision const actingTurn = state.turn; // this action resolves the current turn next.turn = state.turn + 1; // then we advance to the next turn to play next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2); // Committing a turn consumes the whole queued round — reset it for the next. next.pending = []; for (const ev of result.events) { next.events.push(makeEvent(ev, actingTurn, next.date)); } for (const c of result.stateChanges) { const n = next.nations[c.nation]; if (!n) continue; n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value); } for (const r of result.relations) { const a = next.nations[r.a]; const b = next.nations[r.b]; if (!a || !b) continue; a.relations[r.b] = r.set; b.relations[r.a] = r.set; } for (const t of result.territoryChanges) { const n = next.nations[t.nation]; if (!n) continue; for (const code of t.gain) { for (const other of Object.values(next.nations)) { const i = other.territory.indexOf(code); if (i !== -1) other.territory.splice(i, 1); } if (!n.territory.includes(code)) n.territory.push(code); } for (const code of t.lose) { const i = n.territory.indexOf(code); if (i !== -1) n.territory.splice(i, 1); } } return next; } /** * Immutably queue one item onto a round: append it to `pending` and return a new * state. This never advances the turn or the date — that only happens when a * round is committed via runTurn. Bumps `version` (a new revision, so stale SSE * snapshots can never clobber it client-side). Exported so the append-without- * advance behavior is unit-testable, and reused by the /action and /diplomacy * routes. */ export function queueRoundItem(state: GameState, item: RoundItem): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; next.pending.push(item); return next; } /** * Immutably remove one item from the pending round by index and return a new * state (bumping `version`). Returns `null` when the index is missing or not a * valid, in-bounds integer position, so the route can answer 400 without * mutating anything. Reused by the server's /queue/remove route. */ export function removeRoundItem(state: GameState, index: number | undefined): GameState | null { if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) { return null; } const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; next.pending.splice(index, 1); return next; } /** * Run one turn end-to-end against the (injectable) LLM: build prompts, call the * model ONCE over the whole pending batch, parse/validate, retry once with a * nudge if the result is empty/invalid, and — if it is still invalid — emit a * generic "unclear" event and advance. The committed state has `pending` cleared. * Never throws on bad model output; only the underlying transport/network can throw. */ export async function runTurn(opts: { llm: LlmClient; preset: Preset; state: GameState; items: RoundItem[]; }): Promise<{ state: GameState; result: TurnResult }> { const { llm, preset, state, items } = opts; const system = buildSystemPrompt(preset); const user = buildUserPrompt(state, items); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; let result = parseTurnResult(await llm.chat([ { role: 'system', content: system }, { role: 'user', content: user }, ], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); if (result === null || !isMeaningful(result)) { result = parseTurnResult(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 })); } if (result === null || !isMeaningful(result)) { result = { date: addDays(state.date, 2), events: [genericUnclearEvent(state)], stateChanges: [], relations: [], territoryChanges: [], }; } return { state: applyTurnResult(state, result), result }; } // --- Advisor (brainstorm) ----------------------------------------------------- export function buildAdvisorPrompt( state: GameState, prompt?: string, ): { system: string; user: string } { const player = state.nations[state.playerNationId]; const system = [ `${ADVISOR_MARKER} You are a trusted strategic advisor to ${player?.name ?? 'the player'} (${state.playerNationId}).`, 'Given the current world state, brainstorm 3-5 concrete, in-character action ideas the player could take this turn.', 'Respond with ONLY a JSON array of 3-5 short strings. No object, no markdown fences, no prose.', ].join('\n'); const user = JSON.stringify({ player: player ? nationForPrompt(player) : null, date: state.date, turn: state.turn, prompt: prompt ?? null, }); return { system, user }; } /** Parse advisor output (a JSON array of strings) into a clean suggestion list. */ export function parseAdvisor(raw: string): string[] { const t = stripFence(raw); const a = t.indexOf('['); const b = t.lastIndexOf(']'); if (a !== -1 && b > a) { try { const arr = JSON.parse(t.slice(a, b + 1)); if (Array.isArray(arr)) { const out = arr .map((x) => typeof x === 'string' ? x : x && typeof x === 'object' ? String((x as Record).title ?? (x as Record).text ?? '') : '', ) .map((s) => s.trim()) .filter((s) => s.length > 2); if (out.length) return out.slice(0, 5); } } catch { /* fall through to line-splitting */ } } return t .split(/\n+/) .map((l) => l.replace(/^[-*\d.)\s]+/, '').trim()) .filter((l) => l.length > 3) .slice(0, 5); } /** Ask the LLM for 3-5 action ideas; degrades to a canned line if the model fails. */ export async function askAdvisor( llm: LlmClient, state: GameState, prompt?: string, ): Promise { const { system, user } = buildAdvisorPrompt(state, prompt); const raw = await llm.chat([ { role: 'system', content: system }, { role: 'user', content: user }, ], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS }); const out = parseAdvisor(raw); return out.length ? out : ['The council is divided; no clear course of action is advisable this turn.']; }