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