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
+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);
});
});