// 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, MapMarker, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, StatKey, War, } from '../shared/types'; import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { addDays, canConquer, clampStat, deriveNationStats, fnv1a32, isDateLike, isMarkerType, makeEvent, markerId, MAX_MARKERS, nationForPrompt, type MarkerInfo, type MarkerSpec, type NationOp, type RegionStatChange, type Takeover, type TurnEventSpec, type TurnResult, type WarDeclareOp, type WarEndOp, type WarInfo, } from '../shared/protocol'; import { DEFAULT_MAX_TOKENS, type ChatMessage, type LlmClient, type ToolDefinition, type ToolExecutor, type WireMessage, } 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']); // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- /** * The single tool the turn loop exposes to the model: the precomputed region * adjacency manifest (public/region-adjacency.json, via the resolver injected * into runTurn). Given a set of region keys it returns the union of their * bordering keys (the queried keys themselves excluded, sorted for * determinism) — so the model can verify that a territory transfer is * *contiguous* (a nation gains a region it already abuts) before committing * it, instead of moving whole countries at a whim. */ export const GET_NEIGHBOURS_TOOL: ToolDefinition = { type: 'function', function: { name: 'getNeighbours', description: 'List the region keys that share a land border with any of the given region keys. ' + 'Pass a nation\'s territory keys (or a single region key) to see which regions are ' + 'contiguous to it. Use it before transferring territory: a nation can realistically ' + 'only gain regions that border its existing territory.', parameters: { type: 'object', properties: { regionKeys: { type: 'array', items: { type: 'string' }, description: 'One or more region keys to query (e.g. a gaining nation\'s territory).', }, }, required: ['regionKeys'], additionalProperties: false, }, }, }; /** * Build the tool executor for the turn loop. Never throws: unknown tool * names, malformed arguments, and unknown region keys all degrade to a safe * response (an error object or an empty list) that the model can recover from. */ export function makeNeighboursExecutor( getNeighbours: (keys: string[]) => string[], validRegionKeys?: ReadonlySet, ): ToolExecutor { return (name, args) => { if (name !== GET_NEIGHBOURS_TOOL.function.name) { return JSON.stringify({ error: `unknown tool: ${name}` }); } const raw = Array.isArray(args.regionKeys) ? args.regionKeys : []; const keys = raw.filter((k): k is string => typeof k === 'string' && k.length > 0); const valid = validRegionKeys ? keys.filter((k) => validRegionKeys.has(k)) : keys; if (valid.length === 0) return JSON.stringify({ neighbours: [] }); return JSON.stringify({ neighbours: getNeighbours(valid) }); }; } /** * The player's frontier: region keys owned by *other* nations that share a * land border with the player's territory (deduped, sorted — stable order for * the deterministic stub). Computed server-side from the adjacency manifest so * both the LLM (the `frontier` field of the user prompt) and the stub (which * cannot call tools) see the same candidates. Empty when there is no * adjacency data or no controllable border (e.g. the UK in 1939). */ export function playerFrontier( state: GameState, getNeighbours: (keys: string[]) => string[], ): string[] { const player = state.nations[state.playerNationId]; if (!player || player.territory.length === 0) return []; const playerKeys = new Set(player.territory); const candidates = new Set(); for (const key of getNeighbours(player.territory)) { if (!playerKeys.has(key)) candidates.add(key); } if (candidates.size === 0) return []; const frontier: string[] = []; for (const n of Object.values(state.nations)) { if (n.id === state.playerNationId) continue; for (const key of n.territory) { if (candidates.has(key)) { candidates.delete(key); frontier.push(key); } } } return frontier.sort(); } // --- Prompt building ---------------------------------------------------------- /** * The LLM system prompt: world context + rules + a strict JSON contract. * When `withNeighboursTool` is set (the turn runs with the getNeighbours tool * available) the contract gains the contiguity rules for territory transfers * (v0.21, #16 P2): the model may only move regions across a shared border, and * may verify borders with the tool before answering. The contract also * documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the * split/merge shapes applied on player confirm (v0.24, #16 P4b). */ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string { const rules = preset.simulationRules .map((r) => `- ${r}`) .join('\n'); const respondLine = withNeighboursTool ? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:' : 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'; // (v0.22, #16 P3) No man's land: a region can be left unowned after a war or // cataclysm (the model narrates the cause in "events" and lists the key in // "unowned"); unowned regions can later be re-claimed with an ordinary gain. // The model sees the current no-man's-land keys in the user payload's // "unowned" field and in the "regions" reference table. const noMansLandRule = '- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.'; const territoryRules = withNeighboursTool ? [ '- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.', '- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.', noMansLandRule, ] : [ '- Only change borders when war or a decisive political shift justifies it.', noMansLandRule, ]; 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.', respondLine, '{', ' "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>}],', ' "regionChanges": [{"region":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}],', ' "unowned": [""],', ' "nationOps": [', ' {"kind":"rename","nationRef":"","to":""},', ' {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}]}', ' {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""}', ' ],', ' "takeovers": [{"nationRef":"","newRegimeName":""}],', ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', 'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)', '- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.', '- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.', '- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.', '', 'HARD CONSTRAINTS', '- Use only nation ids and region keys that exist in the provided state.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to " ()". Use those keys in territoryChanges and unowned.', '- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".', '- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).', '- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".', '- Rename: add {"kind":"rename","nationRef":"","to":""} for a revolution, regime change or reunification that changes a nation\'s name.', '- Split: add {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.', '- Merge: add {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.', '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', '- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["",""],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"","outcome":""}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".', '- 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.', '- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.', ...territoryRules, '- 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[], regionReference?: Record, /** * The player's frontier: region keys owned by other nations that border the * player's territory (v0.21, #16 P2). Emitted verbatim (even when empty) so * the model — and the deterministic stub, which reads it — see the same * contiguous targets. Omitted entirely when undefined (no adjacency data). */ frontier?: string[], /** * (v0.22, #16 P3) Region keys that are currently "no man's land" (released * mid-game — `state.unowned`). Emitted verbatim (even when empty) so the * model knows which unowned regions exist and can be re-claimed this turn. * Omitted entirely when undefined. */ noMansLand?: string[], /** * (v0.27, #44) The game's current map markers, as the model sees them: * stable id, type, name, region key and current owner. Emitted verbatim * (even when empty) so the model can reference a marker's id in * `TurnResult.markers.remove` or leave an existing marker untouched. * Omitted entirely when undefined. */ markers?: MarkerInfo[], /** * (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them: * stable id, sides and aims. Emitted verbatim (even when empty) so the * model can reference a war's id in `TurnResult.wars.end` to end it. * Omitted entirely when undefined. */ wars?: WarInfo[], ): 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.'; // (v0.29, #46 PR 2) The player's own regions' current per-region stats, so the // model can reason about — and target via `regionChanges` — the player's // individual regions. Bounded to the player's territory (not the whole // world's 1,800 regions) to keep the prompt small. Always emitted: {} when // the player has no region with a stats entry yet. const playerRegionStats: Record = {}; if (state.regionStats) { for (const key of player?.territory ?? []) { const rs = state.regionStats[key]; if (rs) playerRegionStats[key] = rs; } } // (v0.30, #50) A per-neighbor military-balance hint for the player's frontier, // so the model — and, via the same data, the player — sees the hard gate // before it fires: for each frontier neighbor, compare the player's derived // military to the neighbor's and state whether a direct invasion would be // repelled (via canConquer). Computed here (pure) from `state` + `frontier`; // emitted only when a non-empty frontier is present, and the rest of the // payload is left untouched. let frontierMilitary: string[] | undefined; if (frontier !== undefined && frontier.length > 0) { const playerMil = player?.stats.military ?? 0; const ownerByKey = new Map(); // region key -> owning nation id for (const n of Object.values(state.nations)) { for (const k of n.territory) ownerByKey.set(k, n.id); } const byNeighbor = new Map(); // neighbor id -> its military (deduped) for (const k of frontier) { const ownerId = ownerByKey.get(k); if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip const owner = state.nations[ownerId]; if (!owner) continue; if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military); } if (byNeighbor.size > 0) { frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => { const o = state.nations[ownerId]; const wins = canConquer(playerMil, mil); return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${ wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled' }`; }); } } // (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30 // AND that currently have a "gathering" marker on one of their regions are // the ones ripe for a NON-military takeover (a coup / revolution / faction // seizure) — the model can act on them this turn. One line per matching // nation, emitted only when there is at least one; the rest of the payload // is left untouched. A soft nudge only — there is no hard gate behind it. let takeoverRisks: string[] | undefined; if (markers !== undefined && markers.length > 0) { const risks = new Set(); for (const m of markers) { if (m.type !== 'gathering') continue; const ownerId = m.owner; if (!ownerId) continue; const n = state.nations[ownerId]; if (n && n.stats.stability < 30) risks.add(n.id); } if (risks.size > 0) { takeoverRisks = [...risks].map((id) => { const n = state.nations[id]; return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`; }); } } const payload: Record = { 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, playerRegionStats, }; if (regionReference) payload.regions = regionReference; if (frontier !== undefined) payload.frontier = frontier; if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary; if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks; if (noMansLand !== undefined) payload.unowned = noMansLand; if (markers !== undefined) payload.markers = markers; if (wars !== undefined) payload.wars = wars; 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. * (v0.29, #46 PR 2) The optional `regionKeyResolver` is threaded to * `validateTurnResult` to resolve `regionChanges` refs to region keys. */ export function parseTurnResult( raw: string, regionKeyResolver?: (ref: string) => string | null, ): 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, regionKeyResolver); } 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 a raw (LLM) `nationOps` array into safe {@link NationOp} values * (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an * unknown `kind` or missing/empty required fields are dropped, never throw. * Rename: non-empty `nationRef` + `to` (each capped at 200 chars — resolution * of `nationRef` to a concrete nation happens later, at confirm time). Split: * non-empty `nationRef` + at least one part with a name. Merge: non-empty * `nationRefs` (capped at 20) + `survivingRef` + `into`. */ export function validateNationOps(raw: unknown): NationOp[] { if (!Array.isArray(raw)) return []; const out: NationOp[] = []; for (const e of raw) { if (!e || typeof e !== 'object') continue; const o = e as Record; if (o.kind === 'rename') { const nationRef = str(o.nationRef, 200); const to = str(o.to, 200); if (nationRef && to) out.push({ kind: 'rename', nationRef, to }); } else if (o.kind === 'split') { const nationRef = str(o.nationRef, 200); const parts = (Array.isArray(o.parts) ? o.parts : []) .filter((p): p is Record => !!p && typeof p === 'object') .map((p) => { const name = str(p.name, 200); const territory = Array.isArray(p.territory) ? p.territory.filter((k): k is string => typeof k === 'string' && k.trim() !== '') : []; return { name, territory }; }) .filter((p): p is { name: string; territory: string[] } => p.name !== null); if (nationRef && parts.length > 0) out.push({ kind: 'split', nationRef, parts }); } else if (o.kind === 'merge') { const nationRefs = (Array.isArray(o.nationRefs) ? o.nationRefs : []) .map((x) => (typeof x === 'string' ? x.trim().slice(0, 200) : '')) .filter((x) => x !== '') .slice(0, 20); const survivingRef = str(o.survivingRef, 200); const into = str(o.into, 200); if (nationRefs.length > 0 && survivingRef && into) { out.push({ kind: 'merge', nationRefs, survivingRef, into }); } } } return out; } /** * 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. * * (v0.29, #46 PR 2) The optional `regionKeyResolver` resolves each * `regionChanges` ref (key or name) to a concrete region key, dropping * unknown/ambiguous refs; without it the refs are kept as-is (shape-only), so * callers that build a TurnResult by hand (tests) need not supply it. */ export function validateTurnResult( input: unknown, regionKeyResolver?: (ref: string) => string | null, ): 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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; 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 }); } } // (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty // strings only (applied later by applyTurnResult against validKeys). if (Array.isArray(o.unowned)) { for (const k of o.unowned) { const s = typeof k === 'string' ? k.trim() : ''; if (s && !result.unowned.includes(s)) result.unowned.push(s); } } // (v0.23, #16 P4a) Nation-management ops the LLM proposes this turn. These // are NOT applied by the turn — applyTurnResult appends them to the pending // list for the player to confirm/reject. result.nationOps = validateNationOps(o.nationOps); // (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly // by applyTurnResult (automatic, not player-confirmed). result.takeovers = validateTakeovers(o.takeovers); // (v0.33.0, #56) War declare/end ops: creating and ending named Wars. // Applied directly by applyTurnResult (after takeovers, before markers). result.wars = validateWarOps(o.wars); // (v0.27, #44) Map-marker ops: new placements (validated here) and // removals by stable id. Applied by applyTurnResult after the territory // moves (a new marker's owner is the region's post-turn owner). result.markers = validateMarkerOps(o.markers); // (v0.29, #46 PR 2) Per-region stat changes: each targets a single region's // stats (by key or name). Resolved to a key here when a resolver is // supplied; applied by applyTurnResult after the nation-wide stateChanges // and before the derived-stat recompute. result.regionChanges = validateRegionChanges(o.regionChanges, regionKeyResolver); return result; } /** * (v0.27, #44) Validate the raw `markers` object of a turn into a safe * `{ add, remove }` shape. Defensive like the rest of the parser — never * throws. `add`: each spec needs a known marker type, a non-empty `name` * (capped at 120 chars) and a non-empty `regionKey` (capped at 120 chars); * specs whose derived id collides with an earlier one in the same batch * collapse to the first (re-adding is idempotent). `remove`: deduped, * non-empty marker ids (capped at 120 chars). */ export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: string[] } { const out: { add: MarkerSpec[]; remove: string[] } = { add: [], remove: [] }; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; const o = raw as Record; if (Array.isArray(o.add)) { const seen = new Set(); for (const e of o.add) { if (!e || typeof e !== 'object') continue; const s = e as Record; if (!isMarkerType(s.type)) continue; const name = str(s.name, 120); const regionKey = str(s.regionKey, 120); if (!name || !regionKey) continue; const id = markerId(s.type, name, regionKey); if (seen.has(id)) continue; seen.add(id); out.add.push({ type: s.type, name, regionKey }); } } if (Array.isArray(o.remove)) { for (const k of o.remove) { const s = typeof k === 'string' ? k.trim() : ''; if (s && !out.remove.includes(s)) out.remove.push(s.slice(0, 120)); } } return out; } /** * (v0.29, #46 PR 2) Validate the raw `regionChanges` array of a turn into a * safe {@link RegionStatChange} list — the fine-grained, per-region stat lever * that complements the nation-wide `stateChanges`. Defensive like the rest of * the parser — never throws. Each entry needs a non-empty `region` (the * region's stable key, preferred, or its human-readable name — capped at 120 * chars), a known stat, a finite numeric `value`, and an op of `set` or `add`. * * When `regionKeyResolver` is provided, each `region` ref is resolved to a * concrete region key (key first, then a unique case-insensitive name match — * see {@link resolveRegionKey}) and the resolved key is stored; a ref that * resolves to `null` (unknown or ambiguous) is dropped, exactly like an * unresolvable nation ref. When the resolver is absent (pure unit tests), the * ref is kept as-is (shape-only validation) — a name that is not a key simply * no-ops later in `applyTurnResult` (its `regionStats` lookup misses). `set` * values are clamped to 0–100 here; `add` deltas are clamped against the * current value at apply time (they may legitimately exceed 100 in the raw * input, since they are added to an existing 0–100 value). */ export function validateRegionChanges( raw: unknown, regionKeyResolver?: (ref: string) => string | null, ): RegionStatChange[] { if (!Array.isArray(raw)) return []; const out: RegionStatChange[] = []; for (const e of raw) { if (!e || typeof e !== 'object') continue; const c = e as Record; const region = str(c.region, 120); const stat = str(c.stat); if (!region || !stat || !VALID_STATS.has(stat)) continue; if (typeof c.value !== 'number' || !Number.isFinite(c.value)) continue; if (c.op !== 'set' && c.op !== 'add') continue; // Resolve the ref to a key when a resolver is available; drop it when the // resolver says the ref is unknown or ambiguous (never throw). Without a // resolver the ref is kept as-is (a name that is not a key no-ops later in // applyTurnResult, whose regionStats lookup simply misses it). let finalRegion: string; if (regionKeyResolver) { const resolved = regionKeyResolver(region); if (resolved === null) continue; // unknown or ambiguous → drop finalRegion = resolved; } else { finalRegion = region; } out.push({ region: finalRegion, stat: stat as StatKey, op: c.op, value: c.op === 'set' ? clampStat(c.value) : c.value, }); } return out; } /** * (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe * {@link Takeover} list — the non-military regime-change lever (a coup or * revolution that renames a nation to its new regime name). Defensive like * the rest of the parser — never throws. Each entry needs a non-empty * `nationRef` (the target nation's id or name — capped at 200 chars) and a * non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef` * to a concrete nation id happens at apply time (`resolveNationRef`), not * here. Entries with either field missing or whitespace-only are dropped. */ export function validateTakeovers(raw: unknown): Takeover[] { if (!Array.isArray(raw)) return []; const out: Takeover[] = []; for (const e of raw) { if (!e || typeof e !== 'object') continue; const o = e as Record; const nationRef = str(o.nationRef, 200); const newRegimeName = str(o.newRegimeName, 200); if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName }); } return out; } /** * (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe * `{ declare, end }` shape. Defensive like the rest of the parser — never * throws. `declare`: each entry must be an object whose `sides` is an array * of non-empty strings (trimmed, capped at 200 chars each, deduped) that * yields at least TWO distinct sides — anything else (a one-sided or * empty-sided "war") is dropped; `aims`, when an array, is kept only as * non-empty strings (capped at 200 chars, at most 5). `end`: each entry must * be an object with a non-empty `id` (capped at 200 chars) and an `outcome` * of exactly `armistice` or `capitulation` (anything else is dropped); * `terms`, when a non-empty string, is capped at 400 chars. A non-object * input yields `{ declare: [], end: [] }`. */ export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } { const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] }; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; const o = raw as Record; if (Array.isArray(o.declare)) { for (const e of o.declare) { if (!e || typeof e !== 'object') continue; const d = e as Record; const sides = (Array.isArray(d.sides) ? d.sides : []) .map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : '')) .filter((s) => s !== '') // dedupe case-insensitively? No — sides are nation ids: keep exact // strings, drop exact duplicates only (resolution is later). .filter((s, i, arr) => arr.indexOf(s) === i); if (sides.length < 2) continue; // a war needs at least two belligerents const aims = Array.isArray(d.aims) ? (d.aims as unknown[]) .map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : '')) .filter((a) => a !== '') .slice(0, 5) : undefined; out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) }); } } if (Array.isArray(o.end)) { for (const e of o.end) { if (!e || typeof e !== 'object') continue; const w = e as Record; const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : ''; if (!id) continue; if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue; const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : ''; out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) }); } } return out; } /** * (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE * wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c}, * {b,c} (any two distinct belligerents of the same active war are at war — * see the War type's semantics). Pure; exported for the unit tests. */ export function activeWarPairs(wars: War[]): Set { const pairs = new Set(); const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); for (const w of wars) { if (w.status !== 'active') continue; for (let i = 0; i < w.sides.length; i++) { for (let j = i + 1; j < w.sides.length; j++) { pairs.add(key(w.sides[i], w.sides[j])); } } } return pairs; } /** True when nations `a` and `b` are both belligerents of ≥1 active war. */ export function atWar(wars: War[], a: string, b: string): boolean { const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; return activeWarPairs(wars).has(key); } /** * (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars, * MUTATING the (cloned) `next` state in place (the caller passes a clone — * applyTurnResult does) and never touching `prev`. For every unordered pair * of distinct existing nations: * * - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions * of the pair's relation are set to `war` (the engine owns this relation * while the war is active — a manual `relations` entry to a lesser status * on a warring pair is overridden, so the two can never "make peace" * without the war being ended); * - the pair is NOT at war in `next` but WAS at war in `prev` (one of the * wars that tied them ended this turn) AND the current relation is * `war` → both directions become `hostile` (a war is the strongest * hostility — when it stops, the nations stay hostile, not suddenly * neutral); * - otherwise (not at war now, and the relation is not `war`, or the pair * was never tied by an active war) → untouched: the LLM owns * ally/neutral/rival/hostile freely, and a **bare** `war` relation that * no active war ever covered (a preset leftover) stays as-is — an * implicit, unnamed war, never force-created into a War entity and never * clobbered by the sync. * * Never throws. */ export function syncWarRelations(prev: GameState, next: GameState): void { const prevAtWar = activeWarPairs(prev.wars ?? []); const nextAtWar = activeWarPairs(next.wars ?? []); const ids = Object.keys(next.nations); for (let i = 0; i < ids.length; i++) { for (let j = i + 1; j < ids.length; j++) { const a = ids[i]; const b = ids[j]; const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; const na = next.nations[a]; const nb = next.nations[b]; if (nextAtWar.has(key)) { na.relations[b] = 'war'; nb.relations[a] = 'war'; } else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) { na.relations[b] = 'hostile'; nb.relations[a] = 'hostile'; } } } } /** * A turn is "meaningful" if it produced at least an event, a stat change, a * no-man's-land release (v0.22 #16 P3: a release is a real world change even * when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a: * a proposal is a real pending-world change the player will resolve, so it * must survive the retry/nudge path instead of being replaced by the generic * "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended * (v0.33.0 #56: both are real world changes). */ export function isMeaningful(result: TurnResult): boolean { return ( result.events.length > 0 || result.stateChanges.length > 0 || // (v0.29, #46 PR 2) a per-region stat change is a real world change. result.regionChanges.length > 0 || result.unowned.length > 0 || result.nationOps.length > 0 || // (v0.32.0, #54) a takeover is a real world change (regime rename). result.takeovers.length > 0 || // (v0.33.0, #56) a war declared or ended is a real world change. result.wars.declare.length > 0 || result.wars.end.length > 0 || // (v0.27, #44) a marker placed or removed this turn is a real world change. result.markers.add.length > 0 || result.markers.remove.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, territory moves * (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27 * #44). It also appends any proposed nation-ops to the pending list (v0.23 * #16 P4a) without applying them — see the doc comment on `pendingNationOps` * for why that list deliberately survives across turns. * * `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid * (`{ lat, lon, spread }`, from public/region-centroids.json via * server/regions.ts). New markers are positioned at that centroid plus a small * deterministic offset; a spec whose key has no known centroid is dropped. * Without it no marker can be placed (all adds are dropped), which keeps the * engine pure and lets tests inject their own data. * * `geoOf` (v0.28, #46) resolves a region key to its area + population * (from public/region-geo.json via server/regions.ts), used to weight the * end-of-turn recompute of each nation's derived stats as the * area×population-weighted median of its regions' stats. Without it the * recompute degrades to an unweighted median (every region weight 1) — still * region-consistent, just unweighted. The engine stays pure either way. * * `regionNameOf` (v0.30, #50) resolves a region key to its human display name * (from the geo `properties.name`, via server/regions.ts) for the text of the * engine-emitted "invasion repelled" event; without it the event falls back to * the raw key. Pure either way. */ export function applyTurnResult( state: GameState, result: TurnResult, validKeys?: ReadonlySet, centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined, geoOf?: (key: string) => { area: number; population: number } | undefined, regionNameOf?: (key: string) => string | undefined, ): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; // every mutation is a new revision // (v0.28, #46) per-region stats table; a legacy/corrupt clone without the // field starts empty (the turn then re-inits claimed regions as it goes). next.regionStats = next.regionStats ?? {}; 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 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; } // --- Territory (v0.21, #16 P2: at most one owner per key; // v0.22, #16 P3: a key may also be released to "no man's land") --- // // Two kinds of change, applied in this order: // // (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation // CURRENTLY owns (exactly one owner) is removed from that owner, left // ownerless, and added to `GameState.unowned` — the client renders it // as the distinct no-man's-land grey. A key with zero owners (a preset // never assigned it) is never released: doing so would fabricate a // visible no-man's-land out of an ordinary unowned region. A key with // two or more owners (pre-existing corruption) is never touched, and // a key already in `unowned` is a no-op (it is already unowned). // // (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the // releases (snapshot-first): // - a key with exactly one owner that is not the gainer → a TRANSFER: // removed from the old owner, granted to the gainer (v0.21 rules); // - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and // removed from `GameState.unowned` (it leaves no man's land); // - a double claim (two or more distinct gainers) is rejected — the // key stays as it is; a self-gain is a no-op; a pre-existing double // ownership is never touched. // // Invariants after the turn: no key sits in two or more territories; a // released key sits in none; `GameState.unowned` holds exactly the // zero-owner keys that were released mid-game (not the preset-unassigned // ones). "lose" entries remain deliberate no-ops: releasing is the // `unowned` list's job, and a lone loss without a release would just // orphan a key silently. const valid = (k: string) => !validKeys || validKeys.has(k); const unowned = new Set(state.unowned ?? []); // (1) Releases: establish the new no-man's-land set before any claim. for (const key of new Set(result.unowned)) { if (!valid(key) || unowned.has(key)) continue; const owners = Object.values(next.nations) .filter((n) => n.territory.includes(key)) .map((n) => n.id); if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op unowned.add(key); next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key); // (v0.28, #46) a released region leaves no-man's land ownerless — its // per-region stats leave the table with it (no man's land carries none; // a later re-claim re-inits the entry from the gainer, see below). delete next.regionStats[key]; } // (2) Claims against the post-release ownership snapshot. const ownerOf = new Map(); // key -> sole owner id, or '' (2+ owners; 0 = absent) for (const n of Object.values(next.nations)) { for (const key of n.territory) { if (!valid(key)) continue; const cur = ownerOf.get(key); if (cur === undefined) ownerOf.set(key, n.id); else if (cur !== '') ownerOf.set(key, ''); } } // Claim pairs, deduped: one (key, gainer) per pair even if the model emits // duplicate entries; two DIFFERENT gainers for one key is a double claim. const claimPairs = new Set(); for (const t of result.territoryChanges) { if (!next.nations[t.nation]) continue; for (const key of new Set(t.gain)) { if (!valid(key)) continue; claimPairs.add(`${key}\u0000${t.nation}`); } } const claimants = new Map>(); // key -> distinct gainers for (const pair of claimPairs) { const idx = pair.indexOf('\u0000'); const key = pair.slice(0, idx); const gainer = pair.slice(idx + 1); let set = claimants.get(key); if (set === undefined) { set = new Set(); claimants.set(key, set); } set.add(gainer); } for (const [key, claiming] of claimants) { if (claiming.size !== 1) continue; // double claim: the key stays as it was const gainer = [...claiming][0]; const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt) if (owner === '') continue; // pre-existing double ownership: never touched if (owner !== undefined && owner === gainer) continue; // self-gain: no-op // (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a // different sole owner) only proceeds when the attacker's derived military // is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A // re-claim of an unowned key (owner === undefined) is never gated. On // repel, the key stays with its owner, a "repelled" event is emitted (the // engine's own narrative — the model never sees it as an input), and the // gainer is untouched; the engine never throws. if ( owner !== undefined && !canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military) ) { next.events.push( makeEvent( { nation: gainer, type: 'military', title: 'Invasion repelled', text: `${next.nations[gainer].name}'s invasion of ${ regionNameOf?.(key) ?? key } was repelled by ${next.nations[owner].name}.`, outcome: 'repelled', }, actingTurn, next.date, ), ); continue; } for (const n of Object.values(next.nations)) { const i = n.territory.indexOf(key); if (i !== -1) n.territory.splice(i, 1); } next.nations[gainer].territory.push(key); unowned.delete(key); // re-claimed: the key leaves no man's land // (v0.28, #46) a transferred region keeps its own per-region stats (the // conquest is what makes a nation's regions diverge); only a re-claimed // region that has no entry left (it was released earlier this turn) is // re-inited as a copy of the gainer's current stats, so the recompute // below never sees a hole in the table. if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats }; } next.unowned = [...unowned]; // --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to // the regions, then every nation's stats recomputed as the derived // area×population-weighted median of its regions --- // // (a) A nation-level stat change (`stateChanges`) is re-interpreted as // applying to EVERY region the nation owns: each owned region's copy of // that stat gets the same `set`/`add` (clamped to 0–100), and the // nation-level stat is updated too (it is kept as-is for nations the // recompute below does not touch — see (b) — and overwritten for the // rest, where the regions are the source of truth). // // (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted // median of its owned regions' stats (via deriveNationStats, weighted // by area×population when `geoOf` is available, unweighted otherwise). // Two guards keep degenerate states sane: a nation with NO regions // keeps its stats untouched (it has no regions to aggregate), and a // nation none of whose regions has a stats entry keeps its stats // too (deriveNationStats would return zeros, which would be wrong). // This recompute is what makes a conquered region's foreign stats // blend into the conqueror's aggregate and keeps the derived stats in // lockstep with the per-region table across the turn's changes. for (const c of result.stateChanges) { const n = next.nations[c.nation]; if (!n) continue; for (const key of n.territory) { const rs = next.regionStats[key]; if (!rs) continue; // a key with no entry is skipped (recompute does the same) rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value); } n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value); } // (v0.29, #46 PR 2) Fine-grained, per-region stat changes: each targets ONE // region's own stats entry (by its resolved region key). Applied AFTER the // nation-wide stateChanges (so a region-level change overrides the // nation-level one for that single region) and BEFORE the weighted-median // recompute below, which propagates every region's final value up into its // owning nation's derived `stats`. A region with no stats entry (unowned / // no man's land) is a no-op — there is nothing to change. for (const c of result.regionChanges) { const rs = next.regionStats[c.region]; if (!rs) continue; // no entry (unowned) → no-op rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value); } for (const n of Object.values(next.nations)) { if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); } // --- Takeovers (v0.32.0, #54): non-military regime changes (coups / // revolutions) that rename a nation to its new regime. Applied AFTER the // per-region recompute (the rename doesn't depend on territory or stats, // so it is order-independent with respect to those steps) and BEFORE the // markers block (a marker's owner is a nation id, not a name, so the // rename does not affect the owner recompute). // // For each takeover: // 1. Resolve the target nation via `resolveNationRef` (id first, then // case-insensitive unique name). Skip (no-op) if `null` — an // unknown or ambiguous ref is silently dropped, never thrown. // 2. Rename the resolved nation: set `n.name = newRegimeName`. Only // the display name changes — the stable id, territory, relations // (keyed by id), stats, and `playerNationId` (which references the // id, not the name) are all preserved. The player keeps leading an // overthrown nation under its new regime. for (const t of result.takeovers) { const id = resolveNationRef(next, t.nationRef); if (id === null) continue; // unknown / ambiguous ref: no-op const n = next.nations[id]; if (!n) continue; // defensive: id resolved but nation missing (corrupt state) n.name = t.newRegimeName; } // --- Wars (v0.33.0, #56: war as a first-class state) --- // // Wars are named entities (`GameState.wars`), not just a `war` relation. // Two kinds of change, applied in this order against the (cloned) `next`: // // (1) DECLARE: each validated declare is resolved to concrete nation ids // (`resolveNationRef`, so the LLM may use an id OR a unique name) and // becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped // with the acting turn and the turn's new date. Mutually-defensive // allies of the belligerents are DRAGGED IN: any nation that is an // `ally` of a belligerent (per the current, post-relations relations) // and is not already a side joins the war's `sides`. A declare with // fewer than two DISTINCT resolved sides is dropped; a declare whose // sides are all already contained in an existing ACTIVE war is a // duplicate and is skipped (the existing war stands). // (2) END: each validated end targets a war BY ID and sets its status to // the outcome (`armistice` / `capitulation`). Only ACTIVE wars are // affected — an unknown id or an already-ended war is a no-op. // (A war may be declared and ended in the SAME turn: declare runs // first, so the just-declared id is a valid end target.) // (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from // the ACTIVE wars (see its doc) — the load-bearing step that keeps // the relation table honest with the war entities. // // Nothing here throws: every unresolvable / invalid op is dropped. { const takenWarIds = new Set(next.wars.map((w) => w.id)); // (1) DECLARE for (const d of result.wars.declare) { const resolved: string[] = []; for (const ref of d.sides) { const id = resolveNationRef(next, ref); if (id !== null && !resolved.includes(id)) resolved.push(id); } if (resolved.length < 2) continue; // not enough distinct belligerents // Duplicate: an active war already contains all of these sides. const dup = next.wars.some( (w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)), ); if (dup) continue; const sides = [...resolved]; // Mutual-defense drag-in: each belligerent's allies join the war. for (const s of resolved) { const rels = next.nations[s]?.relations ?? {}; for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) { if (!sides.includes(ally)) sides.push(ally); } } const id = freshWarId(takenWarIds); takenWarIds.add(id); next.wars.push({ id, sides, declaredTurn: actingTurn, declaredDate: next.date, ...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}), status: 'active', }); } // (2) END (by id; active only) for (const e of result.wars.end) { const w = next.wars.find((w) => w.id === e.id && w.status === 'active'); if (!w) continue; // unknown id / already ended: no-op w.status = e.outcome; } // (3) RELATION SYNC (prev = the state this turn started from) syncWarRelations(state, next); } // --- Map markers (v0.27, #44) --- // // Markers are anchored to a region key and are processed against the FINAL // post-claim ownership, in this order: // (1) REMOVE: a marker whose id is in result.markers.remove is dropped // (a listed-but-absent id is a no-op). // (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to // its region's current owner — a captured region follows the new // owner, a region released to no man's land (or a key with 0 or 2+ // owners) becomes `null`. A marker always follows its region. // (3) ADD: each new spec (in order) is placed at its region's centroid // (injected via centroidOf) plus a small deterministic per-axis // offset, owned by the region's post-turn owner, and stamped with // the acting turn. Dropped (never throws): a region key that is not // in validKeys or has no known centroid, an id that already exists // (a re-add is idempotent — the existing marker stays), and any adds // beyond MAX_MARKERS (the later ones). { const finalOwner = new Map(); { const owners = new Map(); for (const n of Object.values(next.nations)) { for (const key of n.territory) { if (!valid(key)) continue; const list = owners.get(key); if (list) list.push(n.id); else owners.set(key, [n.id]); } } for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null); } const removeIds = new Set(result.markers.remove); const markers: MapMarker[] = []; for (const m of next.markers ?? []) { if (removeIds.has(m.id)) continue; markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null }); } // Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit // fraction of the marker id (stable across runs and platforms), so // markers of one region scatter naturally instead of stacking. const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => { const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff; return (frac * 2 - 1) * (spread / 2); }; const round6 = (v: number): number => Math.round(v * 1e6) / 1e6; for (const spec of result.markers.add) { const id = markerId(spec.type, spec.name, spec.regionKey); if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess if (!valid(spec.regionKey)) continue; // unknown region key const c = centroidOf?.(spec.regionKey); if (!c) continue; // no centroid data for this key markers.push({ id, type: spec.type, name: spec.name, regionKey: spec.regionKey, lat: round6(c.lat + jitter(id, 'lat', c.spread)), lon: round6(c.lon + jitter(id, 'lon', c.spread)), ownerNationId: finalOwner.get(spec.regionKey) ?? null, placedTurn: actingTurn, }); } next.markers = markers; } // (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they // are appended to the pending list, which (unlike `pending`) SURVIVES // across turns until the player confirms or rejects each one. `next` is a // deep clone of `state`, so `next.pendingNationOps` is a copy of whatever // the state already held (or undefined for a legacy row) — start from that // and append the new proposals, defaulting a missing field to `[]`. next.pendingNationOps = [...(next.pendingNationOps ?? []), ...result.nationOps]; 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; } // --- Nation-management ops (v0.23, #16 P4a: the hybrid "LLM proposes, // player confirms" model) ---------------------------------------------------- // // A NationOp proposed by a turn lands in `GameState.pendingNationOps` (see // applyTurnResult) and waits there across turns. These functions resolve the // player's decision on one pending proposal, by index. Like removeRoundItem // they are pure: clone the state, mutate the clone, bump `version` once, // return the new state — and return `null` for a bad index so the route can // answer 400 without mutating anything. /** True when `index` is a valid, in-bounds integer position in the pending list. */ function validPendingOpIndex(state: GameState, index: number): boolean { const len = state.pendingNationOps?.length ?? 0; return Number.isInteger(index) && index >= 0 && index < len; } /** * Resolve a nation reference (the `nationRef` of a NationOp — the LLM may * write either the nation's stable `id` or its human-readable `name`) to a * concrete nation id: an exact id match wins, then a unique case-insensitive * name match. Returns `null` when the reference is empty, unknown, or * ambiguous (two or more nations share that name) — every caller treats * `null` as "no-op" and nothing here ever throws. */ export function resolveNationRef(state: GameState, ref: string): string | null { const r = (ref ?? '').trim(); if (!r) return null; if (state.nations[r]) return r; // exact id first const matches = Object.values(state.nations).filter( (n) => n.name.trim().toLowerCase() === r.toLowerCase(), ); return matches.length === 1 ? matches[0].id : null; } /** * (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's * `region` — the LLM may write either the region's stable **key** or its * human-readable **name**) to a concrete region key: an exact key match wins, * then a unique case-insensitive name match (via `nameOf`). Returns `null` * when the reference is empty, unknown, or ambiguous (two or more regions * share that name) — every caller treats `null` as "drop this change" and * nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is * the set of all region keys and `nameOf` maps a key to its human name (both * supplied by server/regions.ts). */ export function resolveRegionKey( ref: string, validKeys: ReadonlySet, nameOf: (key: string) => string | undefined, ): string | null { const r = (ref ?? '').trim(); if (!r) return null; if (validKeys.has(r)) return r; // exact key first const lower = r.toLowerCase(); let match: string | null = null; for (const k of validKeys) { const name = nameOf(k); if (name && name.trim().toLowerCase() === lower) { if (match !== null) return null; // ambiguous: two or more regions share the name match = k; } } return match; } /** * Generate a fresh, unused nation id for a newly created nation (split * parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds * each id it creates to `taken` as it goes, so ids stay unique within an * op and are never reused from a removed nation. */ export function freshNationId(taken: ReadonlySet): string { for (let i = 1; ; i++) { const id = `n${i}`; if (!taken.has(id)) return id; } } /** * Generate a fresh, unused war id for a newly declared {@link War} * (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id * it creates to `taken` as it goes, so ids stay unique within a turn and are * never reused from an ended war. */ export function freshWarId(taken: ReadonlySet): string { for (let i = 1; ; i++) { const id = `w${i}`; if (!taken.has(id)) return id; } } /** * The baseline stats a freshly created nation starts with (split parts, * v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT * a copy of the parent's stats (a small rump state is not the parent's * military power). */ export const NEW_NATION_STATS: NationStats = { military: 50, economy: 50, stability: 50, population: 50, }; /** * Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop * the removed nations' ids from every remaining nation's `relations` map so * no key dangles. (The client already degrades gracefully on a missing * target, but consistent data is cleaner.) */ function dropRelationsTo(state: GameState, removedIds: ReadonlySet): void { for (const n of Object.values(state.nations)) { for (const id of removedIds) { if (id in n.relations) delete n.relations[id]; } } } /** * Apply one NationOp to a state, MUTATING the passed object in place (the * caller passes a clone — confirmNationOp does) and NOT bumping `version` * (the route-level functions do that once per decision). It never throws: * any unresolvable reference or malformed op is a silent no-op (the op is * still removed by confirmNationOp — the world simply doesn't change). * * - **rename** (v0.23, #16 P4a): changes only `Nation.name` — the id, its * territory, its relations (keyed by id), and the player pointer all stay * valid, so nothing downstream breaks; a player-supplied `edits.name` * overrides the proposed `to` (an empty edit falls back to the proposal's * own name). * * - **split** (v0.24, #16 P4b): the `nationRef` must resolve and there must * be at least two named parts, else no-op. Parts are processed in order; * each receives the region keys of its `territory` that (a) the parent * owns and (b) no earlier part claimed — the first part to name a key * wins. Keys of the parent's territory that no part claimed go to part 0 * (in the parent's `territory` order), so the parent's territory is fully * partitioned — no key is lost or ever held twice. Each part gets a * FRESH id (`freshNationId`) and the baseline `NEW_NATION_STATS` (not a * copy of the parent's), plus a copy of the parent's relations; the * parent is then removed. If the parent was the player's nation, * `playerNationId` moves to the chosen part (`edits.playerPart`, clamped * into range, default 0); `edits.name` renames that same chosen part. * * - **merge** (v0.24, #16 P4b): every input ref plus the survivor ref must * resolve, at least two inputs must resolve to DISTINCT nations, and the * survivor must be one of them — else no-op. The survivor keeps its id, * keeps its own stats, gains the union of all inputs' territories * (survivor's keys first, then each other input's unclaimed keys in its * own order), and gains a union of the inputs' relations (on a third * party the survivor's own view wins, else the first input's). The other * inputs are removed (their ids dropped from everyone else's relations * maps), and if the player led a removed input, `playerNationId` moves to * the survivor. The merged name is `edits.name` (if non-empty), else * `op.into`, else the survivor's current name. */ export function applyNationOp( state: GameState, op: NationOp, edits?: { name?: string; playerPart?: number }, ): void { if (op.kind === 'rename') { const id = resolveNationRef(state, op.nationRef); if (id === null) return; // invalid/ambiguous ref: no-op const n = state.nations[id]; if (!n) return; const name = (edits?.name ?? '').trim() || op.to.trim(); if (name) n.name = name; return; } if (op.kind === 'split') { const id = resolveNationRef(state, op.nationRef); if (id === null || op.parts.length < 2) return; // invalid ref / not enough parts: no-op const parent = state.nations[id]; if (!parent) return; const taken = new Set(Object.keys(state.nations)); const created: string[] = []; const claimed = new Set(); for (const part of op.parts) { const newId = freshNationId(taken); taken.add(newId); const keys = (part.territory ?? []) .filter((k) => parent.territory.includes(k) && !claimed.has(k)); for (const k of keys) claimed.add(k); const partNation: Nation = { id: newId, type: parent.type, // a part keeps the parent's form of state name: part.name, stats: { ...NEW_NATION_STATS }, territory: keys, relations: { ...parent.relations }, }; state.nations[newId] = partNation; created.push(newId); } // Keys the parent owned but no part claimed go to part 0 (in the // parent's territory order) — nothing is lost or duplicated. for (const k of parent.territory) { if (!claimed.has(k)) state.nations[created[0]].territory.push(k); } // The player chooses which part is its successor (edits.playerPart, // clamped into range; default 0) — only relevant when the parent was // the player's nation; the same part also gets a player-edited name. const partIdx = Math.min(Math.max(edits?.playerPart ?? 0, 0), created.length - 1); if (state.playerNationId === id) state.playerNationId = created[partIdx]; const editedName = (edits?.name ?? '').trim(); if (editedName) state.nations[created[partIdx]].name = editedName; delete state.nations[id]; dropRelationsTo(state, new Set([id])); return; } // merge const resolved: string[] = []; for (const ref of op.nationRefs) { const rid = resolveNationRef(state, ref); if (rid === null) return; // any unresolvable input: no-op resolved.push(rid); } const inputIds = new Set(resolved); const survivorId = resolveNationRef(state, op.survivingRef); if (survivorId === null || !inputIds.has(survivorId) || inputIds.size < 2) return; const survivor = state.nations[survivorId]; if (!survivor) return; // Territory: the union of all inputs — the survivor's keys first (stable // order), then each other input's unclaimed keys in its own order. const territory = [...survivor.territory]; const seen = new Set(territory); for (const rid of resolved) { for (const k of state.nations[rid].territory) { if (!seen.has(k)) { seen.add(k); territory.push(k); } } } // Relations: the union of all inputs' relations; the first input to name a // third party sets it, then the survivor's own view overwrites per key. const relations: Record = {}; for (const rid of resolved) { for (const [other, status] of Object.entries(state.nations[rid].relations)) { if (other === survivorId) continue; // never relate to oneself if (!(other in relations)) relations[other] = status; } } for (const [other, status] of Object.entries(survivor.relations)) { if (other !== survivorId) relations[other] = status; } const removed = new Set(); for (const rid of inputIds) { if (rid === survivorId) continue; delete state.nations[rid]; removed.add(rid); } if (state.playerNationId !== survivorId && removed.has(state.playerNationId)) { state.playerNationId = survivorId; } const name = (edits?.name ?? '').trim() || op.into.trim() || survivor.name; if (name) survivor.name = name; survivor.territory = territory; survivor.relations = relations; dropRelationsTo(state, removed); } /** * Confirm a pending nation-op proposal by index: apply it (a rename takes * effect, optionally with a player-edited name; a split partitions the * nation's territory into the new parts, the player picking which part is * its successor via `edits.playerPart`; a merge folds the inputs into the * survivor, optionally renamed via `edits.name`) and remove it from * `pendingNationOps`, bumping `version`. Returns `null` for an invalid * index, so the route answers 400 without mutating. Reused by the * /nation-op/confirm route. */ export function confirmNationOp( state: GameState, index: number, edits?: { name?: string; playerPart?: number }, ): GameState | null { if (!validPendingOpIndex(state, index)) return null; const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; const op = next.pendingNationOps[index]; applyNationOp(next, op, edits); next.pendingNationOps.splice(index, 1); return next; } /** * Reject (drop) a pending nation-op proposal by index without applying it, * bumping `version`. Returns `null` for an invalid index, so the route * answers 400 without mutating. Reused by the /nation-op/reject route. */ export function rejectNationOp(state: GameState, index: number): GameState | null { if (!validPendingOpIndex(state, index)) return null; const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; next.pendingNationOps.splice(index, 1); return next; } /** * Run one turn end-to-end against the (injectable) LLM: build prompts, 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. * * When `getNeighbours` is provided AND the LLM client implements * `chatWithTools` (v0.21, #16 P2), the turn runs the bounded function- * calling loop with the getNeighbours tool (the model may verify land * borders before committing a territory transfer) and the user prompt gains * the player's `frontier` field. Otherwise it falls back to a plain chat() * call — same as before the tool existed. */ export async function runTurn(opts: { llm: LlmClient; preset: Preset; state: GameState; items: RoundItem[]; /** Region reference table (key -> "name (parent)") to include in the user prompt. */ regionReference?: Record; /** Set of valid region keys; territory changes with other keys are ignored. */ validRegionKeys?: ReadonlySet; /** * Region adjacency resolver (keys -> bordering keys, from the precomputed * manifest). Its presence (together with the client's chatWithTools) * switches the turn onto the getNeighbours tool path. */ getNeighbours?: (keys: string[]) => string[]; /** * (v0.27, #44) Region-key -> precomputed centroid resolver (from * public/region-centroids.json). Present, the turn's new markers can be * placed at their region's centroid; absent, marker adds are dropped and * the existing markers' owners still get recomputed. */ centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined; /** * (v0.28, #46) Region-key -> area/population resolver (from * public/region-geo.json). Present, the end-of-turn recompute of each * nation's derived stats weights each owned region by area×population; * absent, the recompute degrades to an unweighted (uniform) median — * still region-consistent, just unweighted. */ geoOf?: (key: string) => { area: number; population: number } | undefined; /** * (v0.29, #46 PR 2) Region ref resolver: resolves a `regionChanges` entry's * `region` (a region key, preferred, or a human-readable name) to a concrete * region key, or `null` when the ref is unknown or ambiguous (two or more * regions share the name) — the engine drops such entries. Present, the * model may name a region by its name and it is resolved before apply; * absent, refs are kept as-is (a name that is not a key simply no-ops in * applyTurnResult's regionStats lookup). */ regionKeyResolver?: (ref: string) => string | null; /** * (v0.30, #50) Region-key -> human display name resolver (the geo * `properties.name`, from server/regions.ts). Present, the engine's * "invasion repelled" event names the region by its display name; absent, * it falls back to the raw region key. Pure either way. */ regionNameOf?: (key: string) => string | undefined; }): Promise<{ state: GameState; result: TurnResult }> { const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts; const chatWithTools = llm.chatWithTools; const useTools = getNeighbours !== undefined && chatWithTools !== undefined; const system = buildSystemPrompt(preset, useTools); const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined; // (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty // list on a fresh game) so it knows which unowned regions can be re-claimed. // (v0.27, #44) likewise the current map markers, so it can reference their // stable ids in `markers.remove` (or leave them untouched). const markers = (state.markers ?? []).map((m) => ({ id: m.id, type: m.type, name: m.name, regionKey: m.regionKey, owner: m.ownerNationId, })); // (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list // on a fresh game) so it can reference a war's id in `wars.end` to end it; // ended wars stay in GameState.wars as history but are not surfaced. const wars = (state.wars ?? []) .filter((w) => w.status === 'active') .map((w) => ({ id: w.id, sides: w.sides, ...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}), })); const user = buildUserPrompt( state, items, regionReference, frontier, state.unowned ?? [], markers, wars, ); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; /** * One model call with the standard turn sampling: the bounded * function-calling loop when available, else a plain chat(). The nudge * re-send below always starts a FRESH history (no tool context carried * over). */ const call = async (history: WireMessage[]): Promise => { if (useTools) { return await chatWithTools( history, [GET_NEIGHBOURS_TOOL], makeNeighboursExecutor(getNeighbours, validRegionKeys), { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }, ); } return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }); }; let result = parseTurnResult( await call([ { role: 'system', content: system }, { role: 'user', content: user }, ]), regionKeyResolver, ); if (result === null || !isMeaningful(result)) { result = parseTurnResult( await call([ { role: 'system', content: system }, { role: 'user', content: user }, { role: 'assistant', content: '' }, { role: 'user', content: nudge }, ]), regionKeyResolver, ); } if (result === null || !isMeaningful(result)) { result = { date: addDays(state.date, 2), events: [genericUnclearEvent(state)], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; } return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf), 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.']; }