// 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 = '<>'; /** 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 }>; }; 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() }); }