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>
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
// 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() });
|
||
}
|