Add a dynamic scenario generation path alongside the existing 5 static presets: the user picks one of 3 light era templates + a free-text description (+ a year), the LLM generates a draft world + an advisor checkpoint list, the user reviews/approves checkpoints and picks a nation to lead, and a second LLM call finalizes the world into a saved, playable GameState (a normal game at #/game/<id>). The 5 static presets stay untouched — this is purely additive.
1. Two LLM steps (both plain chat(), no tools, no advisor endpoint)
generate: chat() returns BOTH a draft WorldDraftand a list of string checkpoints. No separate advisor endpoint — the advisor is embodied in the checkpoint list.
finalize/commit: chat() returns the final world constrained to the approved checkpoints the user selected.
2. Three light era templates
presets/templates/ with an index.json manifest (mirrors presets/index.json + createPresetStore):
ww2 (1939) — WWII-era powers
present (2026) — the present day
near-future (~2035) — a plausible near future
Each template is a small seed: { id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [string] }. Keep them simple.
Pure validateWorldDraft(raw, { validCountryCodes }): WorldDraft | null — parse/validate/clamp, dedup & assign ids, drop invalid country codes, null if unusable, never throws.
A distinct deterministic generator stub for the scenario calls only. The turn server/stub.ts stays 100% unchanged. The scenario stub returns a fixed small world (3 nations) + a fixed checkpoint list, stable (same input → same output), with a trivial mapping so approved checkpoints affect the output.
6. API routes (server/index.ts, same /api prefix, same-origin)
GET /api/scenarios/templates → the 3 templates
POST /api/scenarios/draft body { templateId, description, year? } → { world: WorldDraft, checkpoints: string[] }
POST /api/scenarios/commit body { templateId, description, year?, approvedCheckpoints: string[], playerNationId } → { id }
All existing routes untouched.
7. Client (Tailwind + shadcn/ui, dark slate/amber/cyan command-center)
src/components/ScenarioForm.tsx — 3 template cards (with default year), description textarea, year number input (pre-filled + editable), Generate → POST /draft.
src/components/CheckpointReview.tsx — draft world summary + checkpoint checklist (approve/remove; approved = bound), reuse NationSelect over the draft's nations, Finalize → POST /commit → navigate #/game/<id>.
src/components/PresetPicker.tsx — add a "Create scenario" entry.
src/App.tsx — create/review flow as client state (home → form → review → navigate); handle LLM errors (failed generate/commit → toast, stay on form/review).
Constraints
shared/ has no runtime deps; change it first.
Never import @turf/@types/geojson from .ts (geo stays in .mjs scripts).
server/stub.ts (turn stub) stays 100% unchanged; the new dynamic e2e is additive.
Bad LLM output is dropped/clamped, never thrown.
Client uses same-relative /api paths (no hardcoded origins).
Version bump to 0.31.0 in package.json; CHANGELOG.md entry.
Acceptance
Unit tests: validateWorldDraft (valid→clean, clamp, bad country codes dropped, garbage→null no throw, id dedup); worldDraftToGameState (territory expansion, regionStats seeded, version:1, unowned/markers empty, playerNationId set, startDate from year); the 3 templates load (manifest + each file valid); generateDraft/finalizeWorld with the scenario stub (deterministic, finalize respects approved checkpoints, returns a saved game); prompt builders include era + description + year + (country reference / approved checkpoints).
New e2e exercising the dynamic flow deterministically under LLM_STUB=1 (template → draft → review/approve → commit → game starts); existing e2e/sim.spec.ts stays unchanged and green.
npm test + typecheck + lint + build green; e2e verified by CI. Deploy (additive, no data wipe) and verify a liveqwen3.8-27b generate + commit (no 502): sensible world + checkpoints from /draft, valid world from /commit + GET /api/games/<id>.
## Summary
Add a **dynamic scenario generation** path alongside the existing 5 static presets: the user picks one of **3 light era templates** + a free-text **description** (+ a year), the LLM **generates** a draft world + an **advisor checkpoint list**, the user reviews/approves checkpoints and picks a nation to lead, and a second LLM call **finalizes** the world into a saved, playable `GameState` (a normal game at `#/game/<id>`). The 5 static presets stay untouched — this is purely additive.
### 1. Two LLM steps (both plain `chat()`, no tools, no advisor endpoint)
- **generate**: `chat()` returns BOTH a draft `WorldDraft` **and** a list of `string` checkpoints. No separate advisor endpoint — the advisor is embodied in the checkpoint list.
- **finalize/commit**: `chat()` returns the final world **constrained to the approved checkpoints** the user selected.
### 2. Three light era templates
`presets/templates/` with an `index.json` manifest (mirrors `presets/index.json` + `createPresetStore`):
- `ww2` (1939) — WWII-era powers
- `present` (2026) — the present day
- `near-future` (~2035) — a plausible near future
Each template is a small seed: `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [string] }`. Keep them simple.
### 3. Shared types (dependency-free, `shared/`)
- `WorldDraft = { nations: WorldNation[]; events?: ScenarioEvent[] }`
- `WorldNation = { id, name, type?, stats: NationStats, territory: string[] (ISO_A3), relations: Record<string, number> (-100..100) }`
- `Checkpoint = string`
- Pure `validateWorldDraft(raw, { validCountryCodes }): WorldDraft | null` — parse/validate/clamp, dedup & assign ids, drop invalid country codes, null if unusable, **never throws**.
- Pure `worldDraftToGameState(draft, expand, playerNationId): GameState` — region-key territory (via `expandCountriesToKeys`), `regionStats` seeded (via `seedRegionStats`), `version:1`, `unowned:[]`, `markers:[]`, `pendingNationOps:[]`, `pending:[]`, `playerNationId`, `startDate` from year, `turn:1`.
### 4. `server/scenario.ts`
- `buildGeneratePrompt`, `buildRegeneratePrompt`
- `generateDraft({ templateId, description, year })` → `{ world: WorldDraft, checkpoints: string[] }`
- `finalizeWorld({ templateId, description, year, approvedCheckpoints, playerNationId })` → final `GameState` (saved via `db.save`, returns new game `id`)
- Both wired to `llm.chat()` with the existing injectable `Transport`.
### 5. `server/stubScenario.ts` — deterministic scenario stub (`LLM_STUB=1`)
A distinct deterministic generator stub for the scenario calls only. The turn `server/stub.ts` stays **100% unchanged**. The scenario stub returns a fixed small world (3 nations) + a fixed checkpoint list, stable (same input → same output), with a trivial mapping so approved checkpoints affect the output.
### 6. API routes (`server/index.ts`, same `/api` prefix, same-origin)
- `GET /api/scenarios/templates` → the 3 templates
- `POST /api/scenarios/draft` body `{ templateId, description, year? }` → `{ world: WorldDraft, checkpoints: string[] }`
- `POST /api/scenarios/commit` body `{ templateId, description, year?, approvedCheckpoints: string[], playerNationId }` → `{ id }`
All existing routes untouched.
### 7. Client (Tailwind + shadcn/ui, dark slate/amber/cyan command-center)
- `src/components/ScenarioForm.tsx` — 3 template cards (with default year), description textarea, year number input (pre-filled + editable), Generate → `POST /draft`.
- `src/components/CheckpointReview.tsx` — draft world summary + checkpoint checklist (approve/remove; approved = bound), reuse `NationSelect` over the draft's nations, Finalize → `POST /commit` → navigate `#/game/<id>`.
- `src/components/PresetPicker.tsx` — add a "Create scenario" entry.
- `src/App.tsx` — create/review flow as client state (home → form → review → navigate); handle LLM errors (failed generate/commit → toast, stay on form/review).
### Constraints
- `shared/` has **no runtime deps**; change it first.
- Never import `@turf`/`@types/geojson` from `.ts` (geo stays in `.mjs` scripts).
- `server/stub.ts` (turn stub) stays **100% unchanged**; the new dynamic e2e is additive.
- Bad LLM output is dropped/clamped, **never thrown**.
- Client uses same-relative `/api` paths (no hardcoded origins).
- Version bump to **0.31.0** in `package.json`; `CHANGELOG.md` entry.
### Acceptance
- Unit tests: `validateWorldDraft` (valid→clean, clamp, bad country codes dropped, garbage→null no throw, id dedup); `worldDraftToGameState` (territory expansion, `regionStats` seeded, `version:1`, `unowned`/`markers` empty, `playerNationId` set, `startDate` from year); the 3 templates load (manifest + each file valid); `generateDraft`/`finalizeWorld` with the scenario stub (deterministic, finalize respects approved checkpoints, returns a saved game); prompt builders include era + description + year + (country reference / approved checkpoints).
- New e2e exercising the dynamic flow deterministically under `LLM_STUB=1` (template → draft → review/approve → commit → game starts); existing `e2e/sim.spec.ts` stays unchanged and green.
- `npm test` + `typecheck` + `lint` + `build` green; e2e verified by CI. Deploy (additive, no data wipe) and verify a **live** `qwen3.8-27b` generate + commit (no 502): sensible world + checkpoints from `/draft`, valid world from `/commit` + `GET /api/games/<id>`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Add a dynamic scenario generation path alongside the existing 5 static presets: the user picks one of 3 light era templates + a free-text description (+ a year), the LLM generates a draft world + an advisor checkpoint list, the user reviews/approves checkpoints and picks a nation to lead, and a second LLM call finalizes the world into a saved, playable
GameState(a normal game at#/game/<id>). The 5 static presets stay untouched — this is purely additive.1. Two LLM steps (both plain
chat(), no tools, no advisor endpoint)chat()returns BOTH a draftWorldDraftand a list ofstringcheckpoints. No separate advisor endpoint — the advisor is embodied in the checkpoint list.chat()returns the final world constrained to the approved checkpoints the user selected.2. Three light era templates
presets/templates/with anindex.jsonmanifest (mirrorspresets/index.json+createPresetStore):ww2(1939) — WWII-era powerspresent(2026) — the present daynear-future(~2035) — a plausible near futureEach template is a small seed:
{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [string] }. Keep them simple.3. Shared types (dependency-free,
shared/)WorldDraft = { nations: WorldNation[]; events?: ScenarioEvent[] }WorldNation = { id, name, type?, stats: NationStats, territory: string[] (ISO_A3), relations: Record<string, number> (-100..100) }Checkpoint = stringvalidateWorldDraft(raw, { validCountryCodes }): WorldDraft | null— parse/validate/clamp, dedup & assign ids, drop invalid country codes, null if unusable, never throws.worldDraftToGameState(draft, expand, playerNationId): GameState— region-key territory (viaexpandCountriesToKeys),regionStatsseeded (viaseedRegionStats),version:1,unowned:[],markers:[],pendingNationOps:[],pending:[],playerNationId,startDatefrom year,turn:1.4.
server/scenario.tsbuildGeneratePrompt,buildRegeneratePromptgenerateDraft({ templateId, description, year })→{ world: WorldDraft, checkpoints: string[] }finalizeWorld({ templateId, description, year, approvedCheckpoints, playerNationId })→ finalGameState(saved viadb.save, returns new gameid)llm.chat()with the existing injectableTransport.5.
server/stubScenario.ts— deterministic scenario stub (LLM_STUB=1)A distinct deterministic generator stub for the scenario calls only. The turn
server/stub.tsstays 100% unchanged. The scenario stub returns a fixed small world (3 nations) + a fixed checkpoint list, stable (same input → same output), with a trivial mapping so approved checkpoints affect the output.6. API routes (
server/index.ts, same/apiprefix, same-origin)GET /api/scenarios/templates→ the 3 templatesPOST /api/scenarios/draftbody{ templateId, description, year? }→{ world: WorldDraft, checkpoints: string[] }POST /api/scenarios/commitbody{ templateId, description, year?, approvedCheckpoints: string[], playerNationId }→{ id }All existing routes untouched.
7. Client (Tailwind + shadcn/ui, dark slate/amber/cyan command-center)
src/components/ScenarioForm.tsx— 3 template cards (with default year), description textarea, year number input (pre-filled + editable), Generate →POST /draft.src/components/CheckpointReview.tsx— draft world summary + checkpoint checklist (approve/remove; approved = bound), reuseNationSelectover the draft's nations, Finalize →POST /commit→ navigate#/game/<id>.src/components/PresetPicker.tsx— add a "Create scenario" entry.src/App.tsx— create/review flow as client state (home → form → review → navigate); handle LLM errors (failed generate/commit → toast, stay on form/review).Constraints
shared/has no runtime deps; change it first.@turf/@types/geojsonfrom.ts(geo stays in.mjsscripts).server/stub.ts(turn stub) stays 100% unchanged; the new dynamic e2e is additive./apipaths (no hardcoded origins).package.json;CHANGELOG.mdentry.Acceptance
validateWorldDraft(valid→clean, clamp, bad country codes dropped, garbage→null no throw, id dedup);worldDraftToGameState(territory expansion,regionStatsseeded,version:1,unowned/markersempty,playerNationIdset,startDatefrom year); the 3 templates load (manifest + each file valid);generateDraft/finalizeWorldwith the scenario stub (deterministic, finalize respects approved checkpoints, returns a saved game); prompt builders include era + description + year + (country reference / approved checkpoints).LLM_STUB=1(template → draft → review/approve → commit → game starts); existinge2e/sim.spec.tsstays unchanged and green.npm test+typecheck+lint+buildgreen; e2e verified by CI. Deploy (additive, no data wipe) and verify a liveqwen3.8-27bgenerate + commit (no 502): sensible world + checkpoints from/draft, valid world from/commit+GET /api/games/<id>.