Files
History/server/scenario.test.ts
T
kamilkrzywda 68402694c5 feat(#52): scenario generator LLM steps + deterministic stub
- server/scenario.ts: buildGeneratePrompt / buildRegeneratePrompt, defensive
  parseGenerate / parseRegenerate (fence-strip + balanced-object + validate),
  generateDraft (draft world + advisor checkpoints) and finalizeWorld (final
  world constrained to approved checkpoints -> playable GameState), all over
  the injectable LlmClient with a nudge re-send. SCENARIO_* markers for the
  stub.
- server/stubScenario.ts: deterministic scenario stub transport (fixed 3-nation
  world + checkpoint list; finalize refines stability per approved checkpoint)
  + makeScenarioStubLlmClient. Distinct from and never touching turn stub.ts.
- server/regions.ts: validCountryCodes() accessor (ISO_A3 parents) for draft
  territory validation.
- shared/scenario.ts: worldDraftToGameState now guards a dangling playerNationId
  (falls back to the first nation) so a finalize that drops it stays playable.
- server/scenario.test.ts: 16 tests (prompt builders, defensive parsing,
  generateDraft/finalizeWorld over a fake client + the stub).

Fixes #52
2026-10-15 09:46:34 +00:00

336 lines
11 KiB
TypeScript

// 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);
});
});