// 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 TurnEventSpec, type TurnResult } from '../shared/protocol'; import type { RoundItem } from '../shared/types'; export const ADVISOR_MARKER = '<>'; /** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */ export interface StubPayload { state?: { turn: number; date: string; playerNationId: string; /** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */ nations: Record; territory?: string[] }>; }; /** The raw pending round the engine emitted (one entry per queued item). */ pending?: RoundItem[]; } 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 per queued * round item (action → military/success, diplomacy → diplomatic/partial), or a * single political "The world moves on" event when the round is empty — so a * caller can predict the exact next state for assertions. It always sets the * player's military to (current + 5) and allies the player with the first other * nation (by key order), advancing the date by 2 days. As of v0.19, when the * round is non-empty it also makes exactly **one** territory move: the player * annexes the lexicographically-first sub-region id of that first other * nation's territory (an empty round makes no move, and a player with nothing * to annex from that nation also makes no move) — so unit tests and e2e can * assert the exact region that changes hands. */ 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 items = p.pending ?? []; // The one deterministic per-turn territory move (see the doc above). const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort(); const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : []; const events: TurnEventSpec[] = items.length === 0 ? [ { nation: pid, type: 'political', title: 'The world moves on', text: 'Weeks pass without a decisive event.', outcome: 'neutral', }, ] : items.map((it) => it.kind === 'action' ? { nation: pid, type: 'military' as const, title: `Action: ${truncate(it.text, 60)}`, text: 'Your move takes effect across the region.', outcome: 'success' as const, } : { nation: pid, type: 'diplomatic' as const, title: 'Diplomatic outreach', text: 'Your message is received.', outcome: 'partial' 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: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [], }; } 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() }); }