// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52): // the prompt builders, the defensive parsers, generateDraft / finalizeWorld // over an injected LlmClient, and the deterministic scenario stub. import { describe, expect, it } from 'vitest'; import { buildGeneratePrompt, buildRegeneratePrompt, finalizeWorld, generateDraft, parseGenerate, parseRegenerate, SCENARIO_GENERATE_MARKER, SCENARIO_REGENERATE_MARKER, } from './scenario'; import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario'; import type { EraTemplate, WorldDraft } from '../shared/scenario'; import type { ChatMessage, LlmClient } from './llm'; const TEMPLATE: EraTemplate = { id: 'near-future', name: 'Near Future', year: 2035, eraDescription: 'A fractured, multipolar 2035.', suggestedPowers: [ { name: 'United States', region: 'USA', note: 'declining hegemon' }, { name: 'China', region: 'CHN', note: 'rising industrial power' }, ], tensions: ['Indo-Pacific standoff', 'Climate migration'], }; const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']); /** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */ function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient { return { async chat(messages) { if (typeof canned === 'function') return canned(messages); return canned ?? '{"world":{"nations":[]},"checkpoints":[]}'; }, async listModels() { return []; }, }; } const GOOD_GENERATE = JSON.stringify({ world: { nations: [ { id: 'alba', name: 'Alba', type: 'major', stats: { military: 70, economy: 60, stability: 65, population: 55 }, territory: ['FRA'], relations: { boreas: -60 }, }, { id: 'boreas', name: 'Boreas', type: 'major', stats: { military: 55, economy: 70, stability: 50, population: 60 }, territory: ['RUS'], relations: { alba: -60 }, }, ], }, checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'], }); const GOOD_REGENERATE = JSON.stringify({ world: { nations: [ { id: 'alba', name: 'Alba', type: 'major', stats: { military: 70, economy: 60, stability: 70, population: 55 }, territory: ['FRA'], relations: { boreas: -70 }, }, { id: 'boreas', name: 'Boreas', type: 'major', stats: { military: 55, economy: 70, stability: 55, population: 60 }, territory: ['RUS'], relations: { alba: -70 }, }, ], }, }); describe('buildGeneratePrompt', () => { it('embeds the era, description, year and the valid country codes', () => { const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES); expect(system).toContain(SCENARIO_GENERATE_MARKER); expect(system).toContain('"checkpoints"'); expect(user).toContain('near-future'); expect(user).toContain('a cold war in space'); expect(user).toContain('2041'); expect(user).toContain('CHN'); expect(user).toContain('validCountryCodes'); }); it('omits the code list when no valid codes are supplied', () => { const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined); expect(system).toContain('standard ISO 3166-1 alpha-3'); expect(user).not.toContain('validCountryCodes'); }); }); describe('buildRegeneratePrompt', () => { it('embeds the approved checkpoints and the draft when present', () => { const draft: WorldDraft = { nations: [ { id: 'alba', name: 'Alba', stats: { military: 70, economy: 60, stability: 65, population: 55 }, territory: ['FRA'], relations: {}, }, ], }; const { system, user } = buildRegeneratePrompt( TEMPLATE, 'desc', 2035, ['Alba presses its advantage.'], VALID_CODES, draft, ); expect(system).toContain(SCENARIO_REGENERATE_MARKER); expect(user).toContain('approvedCheckpoints'); expect(user).toContain('Alba presses its advantage.'); expect(user).toContain('draft'); expect(user).toContain('alba'); }); it('omits the draft field when none is supplied', () => { const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined); expect(user).not.toContain('draft'); }); }); describe('parseGenerate / parseRegenerate (defensive)', () => { it('parses a valid generate answer into a world + checkpoints', () => { const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES); expect(world).not.toBeNull(); expect(world!.nations).toHaveLength(2); expect(world!.nations[0].id).toBe('alba'); expect(world!.nations[0].territory).toEqual(['FRA']); expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']); }); it('strips code fences and drops unknown country codes', () => { const raw = '```json\n' + GOOD_GENERATE + '\n```'; const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set expect(world).not.toBeNull(); expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]); }); it('returns null world + empty checkpoints for garbage', () => { const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES); expect(world).toBeNull(); expect(checkpoints).toEqual([]); }); it('parses a valid regenerate answer (no checkpoints echoed)', () => { const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES); expect(world).not.toBeNull(); expect(world!.nations[0].stats.stability).toBe(70); }); }); describe('generateDraft (injected client)', () => { it('returns a validated world + checkpoints from the model', async () => { const { world, checkpoints } = await generateDraft({ llm: fakeClient(GOOD_GENERATE), template: TEMPLATE, description: 'a cold war in space', year: 2041, validCountryCodes: VALID_CODES, }); expect(world).not.toBeNull(); expect(world!.nations).toHaveLength(2); expect(checkpoints).toHaveLength(2); }); it('nudges once when the first answer is unusable', async () => { let call = 0; const client: LlmClient = { async chat() { call += 1; return call === 1 ? 'total garbage' : GOOD_GENERATE; }, async listModels() { return []; }, }; const { world, checkpoints } = await generateDraft({ llm: client, template: TEMPLATE, description: 'x', year: 2035, validCountryCodes: VALID_CODES, }); expect(call).toBe(2); expect(world).not.toBeNull(); expect(checkpoints).toHaveLength(2); }); it('returns null world when even the nudge fails', async () => { const { world } = await generateDraft({ llm: fakeClient('still no json'), template: TEMPLATE, description: 'x', year: 2035, validCountryCodes: VALID_CODES, }); expect(world).toBeNull(); }); }); describe('finalizeWorld (injected client)', () => { const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]); it('converts the finalized world to a playable GameState', async () => { const state = await finalizeWorld({ llm: fakeClient(GOOD_REGENERATE), template: TEMPLATE, description: 'x', year: 2035, approvedCheckpoints: ['Alba presses its advantage.'], playerNationId: 'alba', validCountryCodes: VALID_CODES, expand, id: 'g-1', title: 'Custom World', startDate: '2035-01-01', }); expect(state.id).toBe('g-1'); expect(state.presetId).toBe('dynamic'); expect(state.playerNationId).toBe('alba'); expect(state.turn).toBe(1); expect(state.date).toBe('2035-01-01'); // FRA expands to two region keys; the per-region stats are seeded. expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']); expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats); }); it('falls back to the first nation when the model drops the player nation', async () => { // The regenerated world has no 'alba' — only 'boreas'. const dropped = JSON.stringify({ world: { nations: [ { id: 'boreas', name: 'Boreas', stats: { military: 55, economy: 70, stability: 55, population: 60 }, territory: ['RUS'], relations: {}, }, ], }, }); const state = await finalizeWorld({ llm: fakeClient(dropped), template: TEMPLATE, description: 'x', year: 2035, approvedCheckpoints: [], playerNationId: 'alba', // not in the world validCountryCodes: VALID_CODES, expand, }); expect(state.playerNationId).toBe('boreas'); }); }); describe('scenario stub (deterministic, LLM_STUB=1)', () => { it('is deterministic: same input → same generate output', async () => { const llm = makeScenarioStubLlmClient(); const a = await generateDraft({ llm, template: TEMPLATE, description: 'd', year: 2035, validCountryCodes: VALID_CODES, }); const b = await generateDraft({ llm, template: TEMPLATE, description: 'completely different description', year: 1999, validCountryCodes: VALID_CODES, }); expect(a.world).toEqual(b.world); expect(a.checkpoints).toEqual(b.checkpoints); expect(a.world!.nations).toHaveLength(3); expect(a.checkpoints).toHaveLength(3); }); it('finalize respects the approved checkpoints (deterministically)', async () => { const baseline = stubWorldFor([]); const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']); const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0; const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0; const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0; const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0; // Approving a checkpoint that names Alba raises Alba's stability only. expect(albaWith).toBe(albaBase + 5); expect(boreasWith).toBe(boreasBase); }); it('finalizeWorld over the stub returns a playable game honoring approvals', async () => { const llm = makeScenarioStubLlmClient(); const state = await finalizeWorld({ llm, template: TEMPLATE, description: 'd', year: 2035, approvedCheckpoints: ['Boreas fortifies its northern frontier.'], playerNationId: 'boreas', validCountryCodes: VALID_CODES, expand: (cs) => cs.flatMap((c) => [`${c}-a`]), id: 'stub-1', }); expect(state.id).toBe('stub-1'); expect(state.playerNationId).toBe('boreas'); expect(state.nations.boreas.territory).toEqual(['RUS-a']); // The approved Boreas checkpoint raised its stability by 5 (50 → 55). expect(state.nations.boreas.stats.stability).toBe(55); }); });