- 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
336 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|