// Deterministic SCENARIO-generation stub (v0.31, #52). // // Distinct from the turn stub (server/stub.ts, which stays untouched): this one // answers the two scenario-generation LLM calls (see server/scenario.ts) so the // whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and // the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it // plugs in as a Transport, and it discriminates the two calls by the markers // scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the // draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how // the turn stub discriminates the advisor call via ADVISOR_MARKER. // // The stub returns a fixed 3-nation world and a fixed checkpoint list for the // generate step, and — for the finalize step — the same world refined in a // simple, deterministic way that depends on the approved checkpoints, so the // "finalize respects approved checkpoints" behavior is observable end to end. import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm'; import { SCENARIO_REGENERATE_MARKER } from './scenario'; import type { WorldDraft } from '../shared/scenario'; /** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */ const WORLD: WorldDraft = { nations: [ { id: 'alba', name: 'Alba', type: 'major', stats: { military: 70, economy: 60, stability: 65, population: 55 }, territory: ['FRA'], relations: { boreas: -60, gamma: 20 }, }, { id: 'boreas', name: 'Boreas', type: 'major', stats: { military: 55, economy: 70, stability: 50, population: 60 }, territory: ['RUS'], relations: { alba: -60, gamma: 10 }, }, { id: 'gamma', name: 'Gamma', type: 'minor', stats: { military: 30, economy: 45, stability: 70, population: 40 }, territory: ['ESP'], relations: { alba: 20, boreas: 10 }, }, ], events: [ { nation: 'alba', type: 'political', title: 'The new order takes shape', text: 'The three powers settle their borders after the great realignment.', outcome: 'neutral', }, ], }; /** The advisor's opening checkpoint list (fixed, in order). */ const CHECKPOINTS: string[] = [ 'Alba dominates the western sea lanes and presses its advantage.', 'Boreas fortifies its northern frontier against a long winter.', 'Gamma brokers a neutral corridor between the two rivals.', ]; /** * Deterministically refine the fixed world to "respect" the approved * checkpoints: each approved checkpoint that names one of the fixed nations (by * the first 4 letters of the nation's name, case-insensitive) raises that * nation's stability by 5 (clamped at 100). Same input → same output, and the * result visibly depends on which checkpoints were approved. */ export function stubWorldFor(approved: string[]): WorldDraft { const nations = WORLD.nations.map((n) => ({ ...n, stats: { ...n.stats }, relations: { ...n.relations }, })); const lower = approved.map((c) => c.toLowerCase()); for (const n of nations) { const key = n.name.toLowerCase().slice(0, 4); if (lower.some((c) => c.includes(key))) { n.stats.stability = Math.min(100, n.stats.stability + 5); } } return { nations, events: WORLD.events }; } export function makeScenarioStubTransport(): 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 ?? ''; const user = req.messages?.find((m) => m.role === 'user'); if (sys.includes(SCENARIO_REGENERATE_MARKER)) { // Finalize step: read the approved checkpoints out of the user payload // and refine the fixed world accordingly (no checkpoints field echoed). const payload = JSON.parse(user?.content ?? '{}') as { approvedCheckpoints?: string[]; }; content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) }); } else { // Generate step (and any unrecognized prompt): the fixed world + list. content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS }); } } catch { content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS }); } 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 scenario transport. */ export function makeScenarioStubLlmClient(): LlmClient { return createLlmClient({ transport: makeScenarioStubTransport() }); }