- 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
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
// 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() });
|
|
}
|