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
This commit is contained in:
@@ -259,6 +259,18 @@ export function validKeys(): ReadonlySet<string> {
|
||||
return data ? data.validKeys : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of ISO_A3 country codes that own at least one sub-region in the
|
||||
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
|
||||
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
|
||||
* values in regions.geo.json. Empty when no region data is loaded; callers
|
||||
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
|
||||
* draft's territory is checked against real countries.
|
||||
*/
|
||||
export function validCountryCodes(): ReadonlySet<string> {
|
||||
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-adjacency manifest loaded with at least one entry.
|
||||
* Callers (the server's jump route) use this to decide whether to hand the
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// Dynamic scenario generation — the two LLM steps (v0.31, #52).
|
||||
//
|
||||
// This is the additive "Create scenario" path. Two plain `chat()` calls (no
|
||||
// tools, no separate advisor endpoint — the advisor is embodied in step 1's
|
||||
// checkpoint list):
|
||||
//
|
||||
// 1. `generateDraft` — from an era template + a free-text description + a
|
||||
// year, the model designs a draft {@link WorldDraft} (nations with ISO_A3
|
||||
// territory, stats, relations, optional backstory) AND an opening
|
||||
// checkpoint list (the advisor's briefing of the world's live tensions).
|
||||
//
|
||||
// 2. `finalizeWorld` — on commit, the model is re-asked to produce the
|
||||
// final world, constrained to the player's APPROVED checkpoints (and,
|
||||
// when the client re-sends the step-1 draft, based on it so the player's
|
||||
// chosen nation survives). The result is converted to a playable
|
||||
// {@link GameState} (region-key territory via the injected `expand`
|
||||
// resolver, per-region stats seeded) and returned for the caller to
|
||||
// persist — a normal game at `#/game/<id>`.
|
||||
//
|
||||
// Both steps are defensive like the rest of the engine: raw model output is
|
||||
// fence-stripped, the first balanced object is extracted, JSON.parsed, and
|
||||
// validated via the pure `validateWorldDraft` (which never throws and returns
|
||||
// `null` for unusable input). A transport-level failure (the model being down)
|
||||
// throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to
|
||||
// a null world, never a crash. The `LlmClient` is injected so unit tests and
|
||||
// the deterministic scenario stub (server/stubScenario.ts) can replace the
|
||||
// network call exactly like the turn path does.
|
||||
import type { LlmClient } from './llm';
|
||||
import { DEFAULT_MAX_TOKENS } from './llm';
|
||||
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||
import { validateWorldDraft } from '../shared/scenario';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { worldDraftToGameState } from '../shared/scenario';
|
||||
|
||||
// --- Defensive parsing (shared with the engine's approach) --------------------
|
||||
|
||||
/** The system-prompt markers the deterministic scenario stub keys off. */
|
||||
export const SCENARIO_GENERATE_MARKER = '<<SCENARIO_GENERATE>>';
|
||||
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
|
||||
|
||||
/** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */
|
||||
function stripFence(text: string): string {
|
||||
let t = text.trim();
|
||||
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||||
if (lead) t = t.slice(lead[0].length);
|
||||
const tail = t.match(/\n?\s*```\s*$/);
|
||||
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||||
function extractBalancedObject(text: string): string | null {
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a raw top-level JSON object, or `null` when none is recoverable. */
|
||||
function parseObject(raw: string): Record<string, unknown> | null {
|
||||
const jsonText = extractBalancedObject(stripFence(raw));
|
||||
if (!jsonText) return null;
|
||||
try {
|
||||
const obj: unknown = JSON.parse(jsonText);
|
||||
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
||||
? (obj as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt building ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shared nation JSON contract text, embedded in both system prompts so the
|
||||
* model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a
|
||||
* valid JSON object" style.
|
||||
*/
|
||||
const WORLD_SHAPE = [
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' },',
|
||||
' "checkpoints": ["short advisor checkpoint", "..."]',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The system prompt for step 1 (generate). Tells the model to design a fresh
|
||||
* world for the era AND to emit an opening advisor checkpoint list.
|
||||
*/
|
||||
export function buildGeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`,
|
||||
'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
WORLD_SHAPE,
|
||||
'',
|
||||
'RULES',
|
||||
'- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'. A nation may control several codes; together the nations should cover a plausible portion of the world.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.',
|
||||
'- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.',
|
||||
'- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.',
|
||||
'- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
suggestedPowers: template.suggestedPowers,
|
||||
tensions: template.tensions,
|
||||
description,
|
||||
year,
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* The system prompt for step 2 (regenerate/finalize). Same world contract, but
|
||||
* the model must now respect the player's APPROVED checkpoints (and, when a
|
||||
* draft is supplied, stay close to it so the player's chosen nation survives).
|
||||
*/
|
||||
export function buildRegeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
approvedCheckpoints: string[],
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
draft?: WorldDraft,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`,
|
||||
'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
[
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
'',
|
||||
'RULES',
|
||||
'- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.',
|
||||
'- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
...(draft ? { draft } : {}),
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// --- Parsing the model's answers ----------------------------------------------
|
||||
|
||||
/** Parse the step-1 (generate) answer into a validated world + checkpoint list. */
|
||||
export function parseGenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): { world: WorldDraft | null; checkpoints: string[] } {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return { world: null, checkpoints: [] };
|
||||
const world = validateWorldDraft(obj.world, { validCountryCodes });
|
||||
const checkpoints: string[] = [];
|
||||
if (Array.isArray(obj.checkpoints)) {
|
||||
const seen = new Set<string>();
|
||||
for (const c of obj.checkpoints) {
|
||||
if (typeof c !== 'string') continue;
|
||||
const t = c.trim();
|
||||
if (t.length > 2 && !seen.has(t)) {
|
||||
seen.add(t);
|
||||
checkpoints.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { world, checkpoints: checkpoints.slice(0, 12) };
|
||||
}
|
||||
|
||||
/** Parse the step-2 (finalize) answer into a validated world (or null). */
|
||||
export function parseRegenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): WorldDraft | null {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return null;
|
||||
return validateWorldDraft(obj.world, { validCountryCodes });
|
||||
}
|
||||
|
||||
// --- Orchestration -------------------------------------------------------------
|
||||
|
||||
export interface GenerateDraftOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: ask the model to design a draft world + an advisor checkpoint list.
|
||||
* Throws only on a transport-level failure (the model is down) — a bad answer
|
||||
* yields `{ world: null, checkpoints: [] }` and is the caller's to surface as
|
||||
* an error, never a crash.
|
||||
*/
|
||||
export async function generateDraft(
|
||||
opts: GenerateDraftOptions,
|
||||
): Promise<{ world: WorldDraft | null; checkpoints: string[] }> {
|
||||
const { llm, template, description, year, validCountryCodes } = opts;
|
||||
const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
const parsed = parseGenerate(out, validCountryCodes);
|
||||
if (parsed.world) return parsed;
|
||||
// One nudge re-send with a fresh history, mirroring the engine's retry.
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
return parseGenerate(retry, validCountryCodes);
|
||||
}
|
||||
|
||||
export interface FinalizeWorldOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
approvedCheckpoints: string[];
|
||||
/** The step-1 draft (re-sent by the client) — the base the final world refines. */
|
||||
draft?: WorldDraft;
|
||||
/** The nation id the player will lead (kept in the final world when present). */
|
||||
playerNationId: string;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
/** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */
|
||||
expand: (countries: string[]) => string[];
|
||||
/** The new game's id (the route sets randomUUID()). */
|
||||
id?: string;
|
||||
/** The scenario title (becomes presetTitle; default 'Custom World'). */
|
||||
title?: string;
|
||||
/** The game's start date (YYYY-MM-DD, from the chosen year). */
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: ask the model to produce the final world constrained to the approved
|
||||
* checkpoints, convert it to a playable {@link GameState}, and return it for the
|
||||
* caller to persist. Throws only on a transport-level failure; a bad answer
|
||||
* that still yields a usable world is committed, and if the final world drops
|
||||
* the player's chosen nation the conversion falls back to the first nation so
|
||||
* the game is always playable.
|
||||
*/
|
||||
export async function finalizeWorld(opts: FinalizeWorldOptions): Promise<GameState> {
|
||||
const {
|
||||
llm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
draft,
|
||||
playerNationId,
|
||||
validCountryCodes,
|
||||
expand,
|
||||
id,
|
||||
title,
|
||||
startDate,
|
||||
} = opts;
|
||||
const { system, user } = buildRegeneratePrompt(
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
validCountryCodes,
|
||||
draft,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
let world = parseRegenerate(out, validCountryCodes);
|
||||
if (!world) {
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
world = parseRegenerate(retry, validCountryCodes);
|
||||
}
|
||||
if (!world) {
|
||||
// The model could not produce a usable final world. Rather than fail the
|
||||
// whole commit, fall back to the draft the player already approved (if any)
|
||||
// so the commit still yields a playable game.
|
||||
if (draft) world = draft;
|
||||
else throw new Error('finalize: the model returned no usable world');
|
||||
}
|
||||
return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate });
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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() });
|
||||
}
|
||||
+12
-1
@@ -385,6 +385,17 @@ export function worldDraftToGameState(
|
||||
};
|
||||
}
|
||||
|
||||
// Guard: a finalize step (or a bad model answer) might drop the nation the
|
||||
// player chose to lead. Rather than produce a game with a dangling
|
||||
// playerNationId, fall back to the first nation (deterministic insertion
|
||||
// order) so the resulting game is always playable.
|
||||
const effectivePlayer =
|
||||
nations[playerNationId] !== undefined
|
||||
? playerNationId
|
||||
: draft.nations.length > 0
|
||||
? draft.nations[0].id
|
||||
: '';
|
||||
|
||||
// Seed the event feed from the draft's optional backstory (turn 1, startDate).
|
||||
const events: EventEntry[] = (draft.events ?? []).map((e) =>
|
||||
makeEvent(e as unknown as TurnEventSpec, 1, startDate),
|
||||
@@ -394,7 +405,7 @@ export function worldDraftToGameState(
|
||||
id,
|
||||
presetId: 'dynamic',
|
||||
presetTitle: title,
|
||||
playerNationId,
|
||||
playerNationId: effectivePlayer,
|
||||
turn: 1,
|
||||
date: startDate,
|
||||
nations,
|
||||
|
||||
Reference in New Issue
Block a user