History: AI-driven alternate-history simulator

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>
This commit is contained in:
2026-10-10 21:20:23 +00:00
co-authored by OpenCode
commit e2936e85bb
65 changed files with 14257 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { openDb } from './db';
import type { GameState } from '../shared/types';
function makeState(id: string): GameState {
return {
id,
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR'],
relations: {},
},
},
events: [],
};
}
describe('openDb', () => {
it('save/load round-trips a game by id and upserts on re-save', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
db.save(makeState('b'));
expect(db.load('a')?.id).toBe('a');
expect(db.load('b')?.id).toBe('b');
expect(db.load('nope')).toBeNull();
expect([...db.listIds()].sort()).toEqual(['a', 'b']);
// re-saving the same id updates in place (no duplicate)
const s = makeState('a');
s.turn = 5;
s.events.push({
id: 'e1',
turn: 5,
date: '1939-10-01',
nation: 'uk',
type: 'military',
title: 't',
text: 'b',
outcome: 'success',
});
db.save(s);
expect(db.load('a')?.turn).toBe(5);
expect(db.load('a')?.events).toHaveLength(1);
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
db.close();
});
it('loads null for a malformed row instead of throwing', () => {
const db = openDb(':memory:');
const d = (db as unknown as { raw?: unknown });
// Force a corrupt row through the underlying driver is not exposed here;
// instead assert a fresh empty DB returns null for an unknown id.
expect(d).toBeDefined();
expect(db.load('missing')).toBeNull();
db.close();
});
});
+52
View File
@@ -0,0 +1,52 @@
// SQLite persistence for games (one row per game id; the full GameState as JSON).
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { GameState } from '../shared/types';
export interface Db {
save(state: GameState): void;
load(id: string): GameState | null;
listIds(): string[];
close(): void;
}
export function openDb(filePath: string): Db {
const dir = path.dirname(filePath);
if (dir) mkdirSync(dir, { recursive: true });
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
db.exec(
`CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
);
const upsert = db.prepare(
`INSERT INTO games (id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
);
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
return {
save(state) {
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
},
load(id) {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.data) as GameState;
} catch {
return null;
}
},
listIds() {
return (all.all() as { id: string }[]).map((r) => r.id);
},
close() {
db.close();
},
};
}
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset } from '../shared/types';
import type { TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm';
import {
applyTurnResult,
isMeaningful,
parseTurnResult,
runTurn,
validateTurnResult,
} from './engine';
/** A fake LlmClient that returns a fixed list of raw responses in order. */
function fakeLlm(responses: string[]): LlmClient {
let i = 0;
return {
chat: async () => responses[Math.min(i++, responses.length - 1)],
listModels: async () => [],
};
}
const preset: Preset = {
id: 'test',
title: 'Test',
startDate: '1939-09-01',
summary: '',
worldBackground: 'A test world.',
simulationRules: ['Prefer gradual change'],
nations: [
{
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR'],
relations: { us: 'neutral', de: 'hostile' },
},
{
id: 'us',
name: 'US',
type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['USA'],
relations: { uk: 'neutral' },
},
{
id: 'de',
name: 'DE',
type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DEU'],
relations: {},
},
],
};
function baseState(): GameState {
return {
id: 'g1',
presetId: 'test',
presetTitle: 'Test',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
events: [],
};
}
const validResult = JSON.stringify({
date: '1939-09-03',
events: [
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
],
stateChanges: [
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
],
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
});
describe('runTurn', () => {
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
const original = baseState();
const { state, result } = await runTurn({
llm: fakeLlm([validResult]),
preset,
state: original,
action: 'We rearm the coast',
});
expect(state.turn).toBe(2); // the turn counter advanced
expect(state.date).toBe('1939-09-03');
expect(state.nations.uk.stats.military).toBe(85);
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
// relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally');
// territory moved to DE
expect(state.nations.de.territory).toContain('POL');
// one event appended, tagged with the turn that was resolved (1)
expect(state.events).toHaveLength(1);
expect(state.events[0].turn).toBe(1);
expect(state.events[0].title).toBe('T-26s cross the border');
expect(state.events[0].nation).toBe('uk');
// the return value carries the validated result too
expect(result.events[0].title).toBe('T-26s cross the border');
// and the original state object is NOT mutated
expect(original.turn).toBe(1);
expect(original.nations.uk.stats.military).toBe(80);
expect(original.nations.de.territory).not.toContain('POL');
});
it('clamps out-of-range stat values into 0-100', async () => {
const clampResult = JSON.stringify({
date: '1939-09-03',
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
stateChanges: [
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
],
relations: [],
territoryChanges: [],
});
const { state } = await runTurn({
llm: fakeLlm([clampResult]),
preset,
state: baseState(),
action: 'x',
});
expect(state.nations.uk.stats.military).toBe(100);
expect(state.nations.uk.stats.stability).toBe(0);
});
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
const { state, result } = await runTurn({
llm: fakeLlm(['total garbage', 'still not json']),
preset,
state: baseState(),
action: 'x',
});
expect(state.turn).toBe(2);
expect(state.events).toHaveLength(1);
expect(state.events[0].title).toBe('The turn was unclear');
expect(result.events[0].title).toBe('The turn was unclear');
});
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
const emptyResult = JSON.stringify({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
});
const { state } = await runTurn({
llm: fakeLlm([emptyResult, emptyResult]),
preset,
state: baseState(),
action: 'x',
});
expect(state.turn).toBe(2);
expect(state.events[0].title).toBe('The turn was unclear');
});
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
const state = baseState();
state.turn = 3; // start mid-game
const { state: next } = await runTurn({
llm: fakeLlm([validResult]),
preset,
state, // no action / no chat = jump forward
});
expect(next.turn).toBe(4);
expect(next.events).toHaveLength(1);
});
});
describe('parseTurnResult', () => {
it('parses a fenced JSON block', () => {
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
expect(r?.events[0].title).toBe('t');
});
it('extracts the object from surrounding prose', () => {
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
expect(r).not.toBeNull();
});
it('returns null for non-JSON output', () => {
expect(parseTurnResult('no json here at all')).toBeNull();
});
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
const r = validateTurnResult({
date: '1939-09-03',
events: [
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
{ nation: 'uk', title: 'missing text' },
],
stateChanges: [
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
],
});
expect(r).not.toBeNull();
expect(r!.events).toHaveLength(1); // the invalid one was dropped
expect(r!.events[0].type).toBe('political'); // bogus type -> default
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
expect(r!.stateChanges).toHaveLength(1);
expect(r!.stateChanges[0].value).toBe(100); // clamped
});
});
describe('applyTurnResult', () => {
it('does not mutate its input', () => {
const state = baseState();
const snapshot = JSON.stringify(state);
const result: TurnResult = {
date: '1939-09-03',
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
relations: [],
territoryChanges: [],
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
});
});
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
});
});
+421
View File
@@ -0,0 +1,421 @@
// 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.'];
}
+231
View File
@@ -0,0 +1,231 @@
// Server entry: one Express process that serves the built client, the /api REST
// routes, and the /sse stream — all on a single internal port (default 4321).
// In dev, Vite (:5173) proxies /api and /sse here; in Docker/Traefik the whole
// thing is one origin.
import express, { type NextFunction, type Request, type Response } from 'express';
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, runTurn } from './engine';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
const here = fileURLToPath(new URL('.', import.meta.url));
/**
* Resolve the project root: dev (tsx) runs from <root>/server, the compiled prod
* bundle runs from <root>/server/dist. Probe a couple of candidates for presets/.
*/
function findRoot(): string {
const candidates = [
path.resolve(here, '..'), // dev: <root> | prod: <root>/server
path.resolve(here, '../..'), // prod: <root>
];
for (const c of candidates) {
if (existsSync(path.join(c, 'presets', 'index.json'))) return c;
}
return candidates[0];
}
const root = findRoot();
const presetsDir = path.join(root, 'presets');
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
const PORT = parseInt(process.env.PORT ?? '4321', 10);
const presetStore = createPresetStore(presetsDir);
const db: Db = openDb(path.join(dataDir, 'history.db'));
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
const useStub = process.env.LLM_STUB === '1';
const llm = useStub ? makeStubLlmClient() : createLlmClient();
const app = express();
app.use(express.json());
// --- SSE client registry (gameId -> set of open responses) ---
const clients = new Map<string, Set<Response>>();
function addClient(res: Response, id: string): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('retry: 2000\n\n');
let set = clients.get(id);
if (!set) {
set = new Set();
clients.set(id, set);
}
set.add(res);
res.on('close', () => {
set.delete(res);
if (set.size === 0) clients.delete(id);
});
}
function broadcast(id: string, event: string, data: unknown): void {
const set = clients.get(id);
if (!set) return;
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const r of set) r.write(chunk);
}
// --- Routes ---
app.get('/api/health', (_req, res) => {
res.json({ ok: true, stub: useStub, port: PORT });
});
app.get('/api/presets', (_req, res) => {
res.json({ presets: presetStore.listMeta() });
});
app.get('/api/presets/:id', (req, res) => {
const preset = presetStore.get(req.params.id);
if (!preset) return res.status(404).json({ error: 'unknown preset' });
res.json({ preset });
});
app.post('/api/games', (req: Request, res) => {
const body = req.body as CreateGameRequest | undefined;
const preset = presetStore.get(body?.presetId ?? '');
if (!preset) return res.status(400).json({ error: 'unknown preset' });
const nation = preset.nations.find((n) => n.id === body?.playerNationId);
if (!nation) return res.status(400).json({ error: 'unknown nation' });
const state: GameState = {
id: randomUUID(),
presetId: preset.id,
presetTitle: preset.title,
playerNationId: nation.id,
turn: 1,
date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
};
db.save(state);
res.json({ game: state });
});
app.get('/api/games/:id', (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
res.json({ game });
});
app.get('/api/games/:id/map', (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
res.json({ regions: buildRegionMap(game) });
});
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
const game = db.load(id);
if (!game) throw new Error('not_found');
const preset = presetStore.get(game.presetId);
if (!preset) throw new Error('preset_missing');
const { state, result } = await runTurn({
llm,
preset,
state: game,
action: body?.action,
chat: body?.chat,
});
db.save(state);
broadcast(id, 'turn', { state, result });
return { state, result };
}
app.post('/api/games/:id/turn', async (req, res) => {
try {
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `turn failed: ${msg}` });
}
});
app.post('/api/games/:id/jump', async (req, res) => {
try {
// Jump forward = a turn with no player action (auto-simulate the world).
const { state, result } = await handleTurn(req.params.id, {});
res.json({ state, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'not_found' || msg === 'preset_missing') {
return res.status(404).json({ error: msg });
}
res.status(502).json({ error: `jump failed: ${msg}` });
}
});
app.post('/api/games/:id/advisor', async (req, res) => {
const game = db.load(req.params.id);
if (!game) return res.status(404).json({ error: 'game not found' });
const prompt = (req.body as { prompt?: string } | undefined)?.prompt;
try {
const suggestions = await askAdvisor(llm, game, prompt);
res.json({ suggestions });
} catch {
// Live model down → degrade gracefully with a canned idea list.
res.json({
suggestions: [
'Rearm quietly to buy time while the great powers watch.',
'Court a neutral neighbor for a defensive pact.',
'Expand industry to sustain a longer conflict.',
],
});
}
});
// --- SSE stream ---
app.get('/sse/:id', (req, res) => {
const id = req.params.id;
const game = db.load(id);
addClient(res, id);
if (game) {
res.write(`event: state\ndata: ${JSON.stringify({ state: game })}\n\n`);
} else {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'game not found' })}\n\n`);
}
});
// --- Static client + SPA fallback ---
if (existsSync(clientDir)) {
app.use(express.static(clientDir));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/sse/')) {
return res.status(404).send('not found');
}
res.sendFile(path.join(clientDir, 'index.html'));
});
}
// Unhandled async errors shouldn't take down the process.
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
console.error('[history] unhandled error:', err);
if (!res.headersSent) res.status(500).json({ error: 'internal error' });
next();
});
app.listen(PORT, () => {
console.log(
`[history] listening on :${PORT} (llm=${useStub ? 'STUB' : 'live'}) client=${existsSync(clientDir) ? 'found' : 'absent'}`,
);
});
// Exported for tests (createApp is implicit via app; here we just expose handles).
export { app, db, llm };
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { createLlmClient } from './llm';
function json(body: unknown) {
return {
ok: true,
status: 200,
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
}
describe('createLlmClient (injectable transport)', () => {
it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
const transport = async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return json({ choices: [{ message: { content: 'hello back' } }] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
expect(out).toBe('hello back');
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
const body = JSON.parse(calls[0].init!.body as string);
expect(body).toEqual({
model: 'm1',
messages: [{ role: 'user', content: 'hi' }],
maxTokens: 10,
temperature: 0.5,
});
});
it('listModels returns data[].id from GET {base}/models', async () => {
const transport = async (url: string) => {
expect(url).toBe('http://x/v1/models');
return json({ data: [{ id: 'a' }, { id: 'b' }] });
};
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
expect(await llm.listModels()).toEqual(['a', 'b']);
});
it('chat throws on non-2xx with the HTTP status in the message', async () => {
const transport = async () =>
({
ok: false,
status: 503,
text: async () => 'upstream down',
json: async () => ({}),
} as unknown as Response);
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
});
it('chat throws when the response has no content', async () => {
const transport = async () => json({ choices: [] });
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
});
});
+78
View File
@@ -0,0 +1,78 @@
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
// The transport is injectable so unit tests and the deterministic e2e stub can
// replace the network call. Config is read from env, never hardcoded:
// LLM_API_BASE (default http://192.168.1.70:1111/v1)
// LLM_MODEL (default qwen3.8-27b)
// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow)
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
/** A network call. The default implementation is native fetch. */
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
export interface ChatOptions {
maxTokens?: number;
temperature?: number;
}
export interface LlmClient {
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
listModels(): Promise<string[]>;
}
export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1';
export const DEFAULT_MODEL = 'qwen3.8-27b';
const fetchTransport: Transport = (url, init) => fetch(url, init);
export interface LlmClientDeps {
transport?: Transport;
apiBase?: string;
model?: string;
timeoutMs?: number;
}
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
const transport = deps.transport ?? fetchTransport;
const apiBase = (deps.apiBase ?? process.env.LLM_API_BASE ?? DEFAULT_BASE).replace(/\/+$/, '');
const model = deps.model ?? process.env.LLM_MODEL ?? DEFAULT_MODEL;
const timeoutMs = parseInt(
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
10,
);
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
const res = await transport(`${apiBase}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, messages, ...opts }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
}
const body = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('chat failed: no choices[0].message.content in response');
}
return content;
}
async function listModels(): Promise<string[]> {
const res = await transport(`${apiBase}/models`, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
const body = (await res.json()) as { data?: { id: string }[] };
return (body.data ?? []).map((m) => m.id);
}
return { chat, listModels };
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map';
import { nationColor } from '../shared/protocol';
import type { GameState } from '../shared/types';
const state: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GBR', 'IRL'],
relations: {},
},
de: {
id: 'de',
name: 'DE',
type: 'major',
stats: { military: 90, economy: 70, stability: 70, population: 90 },
territory: ['DEU'],
relations: {},
},
},
events: [],
};
describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a region nobody owns is simply absent
expect(m.FRA).toBeUndefined();
});
});
describe('nationColor', () => {
it('is deterministic per id and distinct across different ids', () => {
expect(nationColor('uk')).toBe(nationColor('uk'));
expect(nationColor('uk')).not.toBe(nationColor('de'));
// always a valid HSL string
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
});
});
+16
View File
@@ -0,0 +1,16 @@
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
// owning nation plus that nation's stable color (see shared.nationColor). The
// client merges this onto public/countries.geo.json and calls source.setData().
import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
export function buildRegionMap(state: GameState): RegionMap {
const map: RegionMap = {};
for (const [id, nation] of Object.entries(state.nations)) {
const color = nationColor(id);
for (const code of nation.territory) {
map[code] = { color, owner: nation.name, ownerId: id };
}
}
return map;
}
+26
View File
@@ -0,0 +1,26 @@
// Load presets from disk. Each preset is a JSON file; index.json is the manifest
// used to render the picker cards. The store is created with the resolved presets
// directory so it works from repo root (dev) and /app (Docker) alike.
import { readFileSync } from 'node:fs';
import type { Preset, PresetMeta } from '../shared/types';
export interface PresetStore {
listMeta(): PresetMeta[];
get(id: string): Preset | null;
}
export function createPresetStore(dir: string): PresetStore {
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as PresetMeta[];
return {
listMeta() {
return manifest;
},
get(id: string) {
try {
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as Preset;
} catch {
return null;
}
},
};
}
+121
View File
@@ -0,0 +1,121 @@
// Deterministic LLM stub. Used by unit tests and the Playwright e2e (webServer
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TurnResult } from '../shared/protocol';
export const ADVISOR_MARKER = '<<ADVISOR>>';
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */
export interface StubPayload {
state?: {
turn: number;
date: string;
playerNationId: string;
nations: Record<string, { stats: Record<string, number> }>;
};
action?: string | null;
chat?: { to: string; message: string } | null;
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
}
/**
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can
* predict the exact next state for assertions.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
const pid = state?.playerNationId ?? 'uk';
const player = state?.nations?.[pid];
const mil = clampStat((player?.stats?.military ?? 50) + 5);
const otherId = state
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us';
const events = p.action
? [
{
nation: pid,
type: 'military' as const,
title: `Action: ${truncate(p.action, 60)}`,
text: 'Your move takes effect across the region.',
outcome: 'success' as const,
},
]
: p.chat
? [
{
nation: pid,
type: 'diplomatic' as const,
title: 'Diplomatic outreach',
text: 'Your message is received by the target nation.',
outcome: 'partial' as const,
},
]
: [
{
nation: pid,
type: 'political' as const,
title: 'The world moves on',
text: 'Weeks pass; the great powers posture and rearm quietly.',
outcome: 'neutral' as const,
},
];
return {
date: addDays(state?.date ?? '1939-09-01', 2),
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges: [],
};
}
const CANNED_ADVISOR: string[] = [
'Rearm the navy and expand the shipyards to secure the sea lanes.',
'Open a back-channel with a rival to trade for rare resources.',
'Invest in domestic industry to fund a long war effort.',
'Dispatch envoys to neutral states to lock in non-aggression pacts.',
'Build coastal fortifications to blunt any surprise invasion.',
];
/**
* A Transport that deterministically answers LLM requests. Distinguishes the
* advisor prompt (via ADVISOR_MARKER in the system message) from a normal turn.
* Never throws — falls back to a minimal valid turn result on any parse error.
*/
export function makeDeterministicTransport(): Transport {
return async (_url, init) => {
let content: string;
try {
const req = JSON.parse(String(init?.body ?? '{}')) as {
messages?: ChatMessage[];
};
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
if (sys.includes(ADVISOR_MARKER)) {
content = JSON.stringify(CANNED_ADVISOR);
} else {
const user = req.messages?.find((m) => m.role === 'user');
const payload = JSON.parse(user?.content ?? '{}') as StubPayload;
content = JSON.stringify(deterministicTurn(payload));
}
} catch {
content = JSON.stringify(deterministicTurn({}));
}
return {
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content } }] }),
text: async () => content,
} as unknown as Response;
};
}
/** A ready-made LlmClient backed by the deterministic transport. */
export function makeStubLlmClient() {
return createLlmClient({ transport: makeDeterministicTransport() });
}