Pax Historia-style sim where the LLM is the world engine.
- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)
Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
// 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,
|
|
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 type { LlmClient } from './llm';
|
|
import { ADVISOR_MARKER } from './stub';
|
|
|
|
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
|
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
|
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
|
const VALID_STATS = new Set<string>(['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":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
|
' ],',
|
|
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
|
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
|
'}',
|
|
'',
|
|
'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 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 + the player's action/chat. */
|
|
export function buildUserPrompt(
|
|
state: GameState,
|
|
action?: string,
|
|
chat?: { to: string; message: string },
|
|
): string {
|
|
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(-10).map((e) => ({
|
|
turn: e.turn,
|
|
date: e.date,
|
|
nation: e.nation,
|
|
type: e.type,
|
|
title: e.title,
|
|
text: e.text,
|
|
outcome: e.outcome,
|
|
})),
|
|
},
|
|
action: action ?? null,
|
|
chat: chat ?? null,
|
|
};
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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));
|
|
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);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
|
* model, parse/validate, retry once with a nudge if invalid, and — if it is still
|
|
* invalid — emit a generic "unclear" event and advance. Never throws on bad
|
|
* model output; only the underlying transport/network can throw.
|
|
*/
|
|
export async function runTurn(opts: {
|
|
llm: LlmClient;
|
|
preset: Preset;
|
|
state: GameState;
|
|
action?: string;
|
|
chat?: { to: string; message: string };
|
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
|
const { llm, preset, state, action, chat } = opts;
|
|
const system = buildSystemPrompt(preset);
|
|
const user = buildUserPrompt(state, action, chat);
|
|
|
|
let result = parseTurnResult(await llm.chat([
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
], { temperature: 0.4, maxTokens: 2000 }));
|
|
|
|
if (result === null || !isMeaningful(result)) {
|
|
result = parseTurnResult(await llm.chat([
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
{ role: 'assistant', content: '' },
|
|
{
|
|
role: 'user',
|
|
content:
|
|
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
|
|
},
|
|
], { temperature: 0.4, maxTokens: 2000 }));
|
|
}
|
|
|
|
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<string, unknown>).title ?? (x as Record<string, unknown>).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<string[]> {
|
|
const { system, user } = buildAdvisorPrompt(state, prompt);
|
|
const raw = await llm.chat([
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
], { temperature: 0.7, maxTokens: 500 });
|
|
const out = parseAdvisor(raw);
|
|
return out.length
|
|
? out
|
|
: ['The council is divided; no clear course of action is advisable this turn.'];
|
|
}
|