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:
2026-10-15 09:46:34 +00:00
parent 2191514db1
commit 68402694c5
5 changed files with 871 additions and 1 deletions
+12 -1
View File
@@ -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,