// 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 TerritoryChange, 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; nations: Record; territory?: string[] }>; }; /** The raw pending round the engine emitted (one entry per queued item). */ pending?: RoundItem[]; /** * Region keys owned by other nations that share a land border with the * player's territory — the engine's `frontier` field, computed from the * adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it * reads the frontier from the prompt instead. */ frontier?: string[]; } 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, advancing the date by 2 days. * * (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player * gains the first frontier region (sorted order) from its current owner, and * that owner loses it. The stub bypasses the tool loop (it answers with plain * content in one round), so the frontier comes from the prompt payload, not * from a getNeighbours call. With an empty frontier (no controllable border) * no territory change is emitted, keeping every existing assertion stable. */ 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 region transfer: the player takes the first // frontier region (sorted → stable) from whichever other nation owns it. let territoryChanges: TerritoryChange[] = []; const frontier = p.frontier ?? []; if (state && frontier.length > 0) { const key = frontier[0]; const loserId = Object.keys(state.nations).find( (id) => id !== pid && (state.nations[id].territory ?? []).includes(key), ); if (loserId) { territoryChanges = [ { nation: pid, gain: [key], lose: [] }, { nation: loserId, gain: [], lose: [key] }, ]; } } 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, }; } 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() }); }