diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b4c85..3c1e62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to **History** are listed here, newest first. +## v0.31.0 — 2026-10-15 10:22 +- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`. + - **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent). + - **Shared contract (`shared/scenario.ts`, dependency-free).** `EraTemplate` / `TemplateMeta` / `SuggestedPower`; `WorldDraft = { nations: WorldNation[]; events? }` where `WorldNation.territory` holds **ISO_A3 country codes** (not region keys) and `relations` are numeric sentiments (−100..100); `Checkpoint = string`; the four message shapes (`ScenarioDraftRequest/Response`, `ScenarioCommitRequest/Response`). Pure transforms: `validateWorldDraft(raw, { validCountryCodes })` — defensive parse/clamp/normalize that never throws (name-required, id kept-or-slug fallback, duplicate ids suffixed, territory upper-cased/deduped and filtered to known ISO_A3 codes when a valid set is given, relations re-keyed to assigned ids via id or case-insensitive name match with self/unknown refs dropped, optional backstory events kept only when they name a known nation; returns `null` when the result is unusable — no nations, or no valid territory to render); `relationFromScore` (≥60 ally · 0..59 neutral · −40..−1 rival · −80..−41 hostile · ≤−81 war); `worldDraftToGameState(draft, expand, playerNationId, opts)` — expands ISO_A3 territory to region keys via the injected `expand` (`expandCountriesToKeys` in production), seeds `regionStats` (so each owned region starts with its owner's stats and the derived aggregate equals them on turn 1), a clean turn-1 state (`version:1`, `turn:1`, empty `unowned`/`markers`/`pendingNationOps`/`pending`), `presetId: 'dynamic'`, and a guard that re-points a dangling `playerNationId` to the first nation so the game is always playable. + - **Scenario LLM steps (`server/scenario.ts`).** Two plain `chat()` calls (no tools, no separate advisor endpoint — the advisor is embodied in step 1's checkpoint list): `generateDraft({ template, description, year })` → `{ world, checkpoints }` (the prompt carries the era description, suggested powers, tensions, the user's description, the year, and the valid ISO_A3 reference), and `finalizeWorld({ …, approvedCheckpoints, draft, playerNationId })` → final `GameState` (re-asked to produce the final world constrained to the approved checkpoints, based on the step-1 draft the client re-sends so the chosen nation survives; falls back to the draft itself when the model returns nothing usable). Same defensive parsing as the engine (strip fences → first balanced object → `JSON.parse` → `validateWorldDraft`), one nudge-retry when the answer is unusable; a transport failure (model down) throws → HTTP 502, a bad *answer* degrades to the fallback and never crashes. + - **Scenario stub (`server/stubScenario.ts`).** Deterministic transport for the two scenario calls only (discriminated by `SCENARIO_GENERATE_MARKER` / `SCENARIO_REGENERATE_MARKER` in the system message, mirroring how the turn stub keys off `ADVISOR_MARKER`): a fixed 3-nation world (Alba/FRA 70·60·65·55, Boreas/RUS 55·70·50·60, Gamma/ESP 30·45·70·40) + one backstory event ("The new order takes shape") + a fixed 3-checkpoint list; the finalize step refines the world deterministically from the approved checkpoints (each naming a nation by its 4-letter name prefix raises that nation's stability +5, clamped) — so "finalize respects the approved checkpoints" is observable end-to-end. The turn stub stays 100% unchanged. + - **Routes (`server/index.ts`).** `GET /api/scenarios/templates` (the 3 manifest entries), `POST /api/scenarios/draft` (`{ templateId, description, year? }` → `{ world, checkpoints }`), `POST /api/scenarios/commit` (`{ templateId, description, year?, draft?, approvedCheckpoints, playerNationId }` → `{ id }` — the finalized world is saved to SQLite with `startDate = -01-01` and title = the template name). A separate `scenarioLlm` client (the scenario stub under `LLM_STUB=1`, the same live `qwen3.8-27b` client otherwise) — the turn `llm` is untouched. `POST /api/games/:id/jump` synthesizes a minimal `Preset` (`dynamicPresetFromGame`) for a saved dynamic game (`presetId: 'dynamic'` has no static file on disk — `runTurn` only consumes title/startDate/worldBackground/rules) so a created scenario plays with the normal turn engine; a genuinely-missing static preset still 404s. + - **Client.** New `ScenarioForm` (3 template cards, a pre-filled editable year, a description textarea, Generate → `/draft`; a 502 toasts and stays on the form) and `CheckpointReview` (the draft world as nation cards with stats, the checkpoint checklist with per-item approve/remove, a pick-a-nation-to-lead step, Finalize → `/commit` → navigates to `#/game/`; a 502 toasts and stays on review). `PresetPicker` gains a "Create scenario" card, and `App.tsx` wires the `home → form → review → navigate` flow (a distinct `scenarioBusy` flag that never gates "Jump forward" or the turn flow). + - **Tests.** Unit: the template store (manifest + all 3 files' shape); `validateWorldDraft` / `relationFromScore` / `worldDraftToGameState` (valid → clean, stat/score clamping, bad country codes dropped, garbage → null without throwing, id dedup, territory expansion, `regionStats` seeding, player fallback, turn-1 invariants); `generateDraft` / `finalizeWorld` against the scenario stub (deterministic output, finalize respecting the approved checkpoints, a saved playable game). E2E: a new `e2e/scenario.spec.ts` (2 tests — the templates manifest over the API, and the full deterministic flow under `LLM_STUB=1`: near-future (2035) + description → draft → remove one checkpoint → lead Boreas → finalize (checkpoints shift stats) → a playable game at the `#/game/` deep link with turn 1, correct date, dossier stats, map legend, and the backstory event; plus an empty jump proving the dynamic game runs the normal turn loop). The existing `e2e/sim.spec.ts` is unchanged. Totals: **307 unit tests (14 files)**, **16 e2e tests (2 scenario + 14 sim)**; typecheck, lint, and build green. + ## v0.30.0 — 2026-10-15 08:22 - **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there). - **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender). diff --git a/e2e/scenario.spec.ts b/e2e/scenario.spec.ts new file mode 100644 index 0000000..a5d726b --- /dev/null +++ b/e2e/scenario.spec.ts @@ -0,0 +1,253 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic + * under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so + * the two generation calls ride the deterministic transport in + * server/stubScenario.ts — the turn stub in server/stub.ts is untouched). + * + * The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for + * the generate step, and — on the finalize step — refines that world in a + * deterministic way that depends on which checkpoints the player approved + * (each approved checkpoint naming one of the fixed nations, by its first 4 + * letters, raises that nation's stability by 5). So the whole two-step + * "Create scenario" path plus the resulting game is reproducible: + * + * home → "Create scenario" → pick the Near Future era (2035) + a description + * → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the + * 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved, + * playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump + * (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no + * territory change — Russia shares no land border with France or Spain). + * + * The e2e webServer shares one SQLite (data/e2e) across tests, so the test + * captures the *specific* id of the game it creates (from the POST + * /api/scenarios/commit response) rather than assuming "the only game". + */ + +/** The saved-game shape the assertions read back from GET /api/games/. */ +interface SavedGame { + id: string; + presetId: string; + presetTitle: string; + playerNationId: string; + turn: number; + date: string; + version: number; + pending: unknown[]; + unowned: string[]; + pendingNationOps: unknown[]; + markers: unknown[]; + events: Array<{ type: string; outcome: string; title: string }>; + nations: Record< + string, + { + name: string; + stats: { military: number; economy: number; stability: number; population: number }; + territory: string[]; + relations: Record; + } + >; + regionStats: Record< + string, + { military: number; economy: number; stability: number; population: number } + >; +} + +/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */ +async function waitHash(page: Page, expected: string): Promise { + await page.waitForFunction( + (exp) => window.location.hash === exp, + expected, + { timeout: 15000 }, + ); +} + +/** Open the "Create scenario" form from the home screen. */ +async function openScenarioForm(page: Page): Promise { + await page.goto('/'); + // Home: the create-scenario card is additive (first in the grid); the five + // static presets are still all present alongside it. + await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 }); + await expect(page.getByTestId('preset-card')).toHaveCount(5); + await page.getByTestId('create-scenario').click(); + await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 }); +} + +test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => { + test('the era template manifest renders three light templates', async ({ page }) => { + test.setTimeout(60000); + await openScenarioForm(page); + + // Exactly three era-template cards, each carrying its id and default year. + const cards = page.getByTestId('template-card'); + await expect(cards).toHaveCount(3, { timeout: 15000 }); + await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939'); + await expect(page.locator('[data-template-id="present"]')).toContainText('2026'); + await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035'); + }); + + test('full create → review → finalize → play flow is deterministic', async ({ page }) => { + test.setTimeout(120000); + await openScenarioForm(page); + + // --- 1 · Pick the Near Future era (its default year pre-fills the field). + const nf = page.locator('[data-template-id="near-future"]'); + await nf.click(); + await expect(nf).toHaveAttribute('data-selected', 'true'); + // The year field is pre-filled from the era (editable, a real value). + await expect(page.getByTestId('scenario-year')).toHaveValue('2035'); + + // --- 2 · Describe the world. + const description = + 'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.'; + await page.getByTestId('scenario-description').fill(description); + + // --- Step 1 · Generate the draft world + the advisor's checkpoint list. + const generate = page.getByTestId('generate-scenario'); + await expect(generate).toBeEnabled(); + const [draftResp] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft', + ), + generate.click(), + ]); + expect(draftResp.status()).toBe(200); + const draft = (await draftResp.json()) as { + world: { + nations: Array<{ id: string; name: string; territory: string[] }>; + }; + checkpoints: string[]; + }; + // The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints. + expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']); + expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']); + expect(draft.checkpoints).toHaveLength(3); + + // --- Review screen: the generated world + the checkpoint checklist. + await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 }); + // Header shows the era's name + year and the description. + await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible(); + await expect(page.getByTestId('scenario-review')).toContainText('2035'); + await expect(page.getByTestId('scenario-review')).toContainText(description); + // The three draft nations (with their ISO_A3 territory) and the backstory. + // Scope to the draft-nation cards (the nation-pick cards share the + // data-nation-id attribute but carry a different testid). + await expect(page.getByTestId('draft-nation')).toHaveCount(3); + await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA'); + await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS'); + await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP'); + await expect(page.getByTestId('draft-event')).toHaveCount(1); + // Three checkpoints, all approved by default, in the stub's fixed order. + const cps = page.getByTestId('checkpoint-item'); + await expect(cps).toHaveCount(3); + await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true'); + await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true'); + await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true'); + await expect(cps.nth(0)).toContainText('Alba dominates'); + await expect(cps.nth(1)).toContainText('Boreas fortifies'); + await expect(cps.nth(2)).toContainText('Gamma brokers'); + // Three nation-pick cards; finalize is disabled until a nation is chosen. + await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3); + await expect(page.getByTestId('finalize-scenario')).toBeDisabled(); + + // --- Review: remove checkpoint 3, keep 1 + 2. + await cps.nth(2).getByTestId('checkpoint-toggle').click(); + await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false'); + await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true'); + await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true'); + await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved'); + + // --- Review: choose Boreas (Russia) to lead. + const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]'); + await boreasCard.getByTestId('scenario-lead-nation').click(); + await expect(boreasCard).toHaveAttribute('data-selected', 'true'); + await expect(page.getByTestId('finalize-scenario')).toBeEnabled(); + + // --- Step 2 · Finalize: commit the approved draft into a saved game. + const [commitResp] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit', + ), + page.getByTestId('finalize-scenario').click(), + ]); + expect(commitResp.status()).toBe(200); + const { id } = (await commitResp.json()) as { id: string }; + expect(id).toBeTruthy(); + + // --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01. + await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('turn-count')).toHaveText('1'); + await expect(page.getByTestId('game-date')).toHaveText('2035-01-01'); + await expect(page.getByTestId('player-nation')).toHaveText('Boreas'); + // The scenario title (the era name) is shown in the top bar. + await expect(page.getByTestId('top-bar')).toContainText('Near Future'); + // Boreas's stats: the finalized world (its approved checkpoint raised + // stability 50→55); per-region stats are seeded to equal the derived. + await expect(page.getByTestId('stat-military')).toHaveText('55'); + await expect(page.getByTestId('stat-economy')).toHaveText('70'); + await expect(page.getByTestId('stat-stability')).toHaveText('55'); + await expect(page.getByTestId('stat-population')).toHaveText('60'); + // One backstory event; three nations on the legend. + await expect(page.getByTestId('event-item')).toHaveCount(1); + await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 }); + await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible(); + // The deep link lands on this exact game. + await waitHash(page, `#/game/${id}`); + + // --- One empty jump: the world advances on its own (the turn stub). + // The button sits at the bottom of a scrollable window body and may be + // outside the viewport; a native DOM click bypasses the viewport check. + await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click()); + + // Turn 2, date +2 days, Boreas military 55→60. + await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); + await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 }); + await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 }); + // Two events now (the empty-round "world moves on" on top, then the + // backstory); both political/neutral. + await expect(page.getByTestId('event-item')).toHaveCount(2); + await expect(page.getByTestId('event-item').first()).toContainText('The world moves on'); + await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political'); + await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral'); + await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape'); + // No territory change: Russia shares no land border with France or Spain, + // so the frontier is empty and no conquest is emitted. + await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 }); + + // --- The saved game is a coherent dynamic GameState on the server. + const saved = await page.evaluate(async (gid) => { + const r = await fetch(`/api/games/${gid}`); + return { status: r.status, body: (await r.json()) as { game: SavedGame } }; + }, id); + expect(saved.status).toBe(200); + const g = saved.body.game; + expect(g.presetId).toBe('dynamic'); + expect(g.presetTitle).toBe('Near Future'); + expect(g.playerNationId).toBe('boreas'); + expect(g.turn).toBe(2); + expect(g.date).toBe('2035-01-03'); + expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump + expect(g.pending).toEqual([]); + expect(g.unowned).toEqual([]); + expect(g.pendingNationOps).toEqual([]); + expect(g.markers).toEqual([]); + expect(g.events).toHaveLength(2); + // Boreas after the jump: military bumped to 60, the rest untouched. + expect(g.nations.boreas.stats).toEqual({ + military: 60, + economy: 70, + stability: 55, + population: 60, + }); + // The stub allies the player with the first other nation (Alba). + expect(g.nations.boreas.relations.alba).toBe('ally'); + // Every owned region key carries a seeded per-region stats entry. + const allKeys = new Set(); + for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k); + expect(allKeys.size).toBeGreaterThan(0); + for (const k of allKeys) { + expect(g.regionStats[k]).toBeDefined(); + } + }); +}); diff --git a/package.json b/package.json index e3445fe..57046a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.30.0", + "version": "0.31.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/presets/templates/index.json b/presets/templates/index.json new file mode 100644 index 0000000..56ad988 --- /dev/null +++ b/presets/templates/index.json @@ -0,0 +1,20 @@ +[ + { + "id": "ww2", + "name": "World War II", + "year": 1939, + "eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength." + }, + { + "id": "present", + "name": "Present Day", + "year": 2026, + "eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision." + }, + { + "id": "near-future", + "name": "Near Future", + "year": 2035, + "eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances." + } +] diff --git a/presets/templates/near-future.json b/presets/templates/near-future.json new file mode 100644 index 0000000..23e7492 --- /dev/null +++ b/presets/templates/near-future.json @@ -0,0 +1,17 @@ +{ + "id": "near-future", + "name": "Near Future", + "year": 2035, + "eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.", + "suggestedPowers": [ + { "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." }, + { "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." }, + { "name": "European Confederation", "region": "FRA", "note": "The united European bloc." }, + { "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." } + ], + "tensions": [ + "Autonomous weapons and the new arms race", + "Climate migration and resource wars", + "The contest for space and orbital dominance" + ] +} diff --git a/presets/templates/present.json b/presets/templates/present.json new file mode 100644 index 0000000..116fa30 --- /dev/null +++ b/presets/templates/present.json @@ -0,0 +1,17 @@ +{ + "id": "present", + "name": "Present Day", + "year": 2026, + "eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.", + "suggestedPowers": [ + { "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." }, + { "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." }, + { "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." }, + { "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." } + ], + "tensions": [ + "US–China tech and trade rivalry", + "The war in Europe and the eastern flank", + "AI and cyber dominance" + ] +} diff --git a/presets/templates/ww2.json b/presets/templates/ww2.json new file mode 100644 index 0000000..bf085df --- /dev/null +++ b/presets/templates/ww2.json @@ -0,0 +1,17 @@ +{ + "id": "ww2", + "name": "World War II", + "year": 1939, + "eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.", + "suggestedPowers": [ + { "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." }, + { "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." }, + { "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." }, + { "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." } + ], + "tensions": [ + "The Axis versus the Allies", + "The race for continental resources and sea lanes", + "The uneasy balance before the bomb" + ] +} diff --git a/server/index.ts b/server/index.ts index bda31c9..c755721 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,13 +17,17 @@ import type { RejectNationOpRequest, RemoveQueueItemRequest, } from '../shared/protocol'; -import type { GameState } from '../shared/types'; +import type { GameState, Preset } from '../shared/types'; +import type { WorldDraft } from '../shared/scenario'; import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; +import { makeScenarioStubLlmClient } from './stubScenario'; +import { finalizeWorld, generateDraft } from './scenario'; import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine'; import { buildRegionMap } from './map'; -import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions'; +import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validCountryCodes, validKeys } from './regions'; import { createPresetStore } from './presets'; +import { createTemplateStore } from './templates'; import { openDb, type Db } from './db'; const here = fileURLToPath(new URL('.', import.meta.url)); @@ -52,6 +56,8 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data'); // the e2e config, so honor it as a fallback to keep all three in agreement. const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10); const presetStore = createPresetStore(presetsDir); +// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/). +const templateStore = createTemplateStore(path.join(root, 'presets', 'templates')); // Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file // just means region expansion/reference degrade to the raw keys (server still boots). loadRegions(root); @@ -59,6 +65,10 @@ const db: Db = openDb(path.join(dataDir, 'history.db')); // LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model). const useStub = process.env.LLM_STUB === '1'; const llm = useStub ? makeStubLlmClient() : createLlmClient(); +// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario +// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays +// untouched), and the live client otherwise. +const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm; const app = express(); app.use(express.json()); @@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void { // --- Routes --- +/** + * (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a + * dynamically generated game (`presetId: 'dynamic'` — there is no static preset + * file on disk). `runTurn` only consumes `title` / `startDate` / + * `worldBackground` / `simulationRules` (never `preset.nations`), so a generic + * alternate-history background plus the standard rules is sufficient; `nations` + * is carried from the saved world so the type is complete. `startDate` uses the + * game's current date (GameState stores no separate start date) — it only feeds + * the static "WORLD: — <date>" header of the system prompt. + */ +function dynamicPresetFromGame(game: GameState): Preset { + return { + id: game.presetId, + title: game.presetTitle, + startDate: game.date, + summary: game.presetTitle, + worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`, + simulationRules: [ + "Advance the shared world for ALL nations, not only the player's.", + 'Keep every stat value between 0 and 100.', + 'Only change borders when war or a decisive political shift justifies it.', + 'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.', + 'Use only nation ids and region keys that exist in the provided state.', + ], + nations: Object.values(game.nations), + }; +} + app.get('/api/health', (_req, res) => { res.json({ ok: true, stub: useStub, port: PORT }); }); @@ -278,8 +316,14 @@ app.post('/api/games/:id/jump', async (req, res) => { const id = req.params.id; const game = db.load(id); if (!game) return res.status(404).json({ error: 'game not found' }); - const preset = presetStore.get(game.presetId); - if (!preset) return res.status(404).json({ error: 'preset_missing' }); + let preset = presetStore.get(game.presetId); + if (!preset) { + // (v0.31, #52) A dynamically generated game has no static preset file: + // synthesize one from the saved world so the turn engine can run. A + // genuinely-missing static preset still 404s. + if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' }); + preset = dynamicPresetFromGame(game); + } try { const { state, result } = await runTurn({ llm, @@ -347,6 +391,86 @@ app.post('/api/games/:id/advisor', async (req, res) => { } }); +// --- Dynamic scenario generation (v0.31, #52) --- +// +// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft +// world + an advisor checkpoint list; the client shows both, the player +// approves/removes checkpoints and picks a nation; step 2 (POST /commit) +// finalizes the world (constrained to the approved checkpoints) into a saved, +// playable game. Both steps ride the scenario LLM client (the deterministic +// scenario stub under LLM_STUB=1, the live model otherwise). All existing +// routes are untouched. + +app.get('/api/scenarios/templates', (_req, res) => { + res.json({ templates: templateStore.listMeta() }); +}); + +app.post('/api/scenarios/draft', async (req, res) => { + const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number }; + const template = templateStore.get(body.templateId ?? ''); + if (!template) return res.status(400).json({ error: 'unknown template' }); + const description = typeof body.description === 'string' ? body.description.trim() : ''; + const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year; + const codes = validCountryCodes(); + try { + const { world, checkpoints } = await generateDraft({ + llm: scenarioLlm, + template, + description, + year, + // No region data → no code filtering (the draft keeps whatever codes the + // model emitted; expansion degrades to the raw keys, like presets do). + validCountryCodes: codes.size > 0 ? codes : undefined, + }); + if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' }); + res.json({ world, checkpoints }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(502).json({ error: `generate failed: ${msg}` }); + } +}); + +app.post('/api/scenarios/commit', async (req, res) => { + const body = (req.body ?? {}) as { + templateId?: string; + description?: string; + year?: number; + draft?: WorldDraft; + approvedCheckpoints?: string[]; + playerNationId?: string; + }; + const template = templateStore.get(body.templateId ?? ''); + if (!template) return res.status(400).json({ error: 'unknown template' }); + const description = typeof body.description === 'string' ? body.description.trim() : ''; + const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year; + const approvedCheckpoints = Array.isArray(body.approvedCheckpoints) + ? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '') + : []; + const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : ''; + const codes = validCountryCodes(); + try { + const state = await finalizeWorld({ + llm: scenarioLlm, + template, + description, + year, + approvedCheckpoints, + draft: body.draft, + playerNationId, + validCountryCodes: codes.size > 0 ? codes : undefined, + expand: (cs) => expandCountriesToKeys(cs), + id: randomUUID(), + title: template.name, + startDate: `${year}-01-01`, + }); + db.save(state); + res.json({ id: state.id }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(502).json({ error: `commit failed: ${msg}` }); + } +}); + // --- SSE stream --- app.get('/sse/:id', (req, res) => { const id = req.params.id; @@ -384,4 +508,4 @@ app.listen(PORT, () => { }); // Exported for tests (createApp is implicit via app; here we just expose handles). -export { app, db, llm }; +export { app, db, llm, scenarioLlm, templateStore }; diff --git a/server/regions.ts b/server/regions.ts index 4f7a800..27ec3cd 100644 --- a/server/regions.ts +++ b/server/regions.ts @@ -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 diff --git a/server/scenario-shared.test.ts b/server/scenario-shared.test.ts new file mode 100644 index 0000000..8d88edc --- /dev/null +++ b/server/scenario-shared.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; +import { + relationFromScore, + validateWorldDraft, + worldDraftToGameState, + type WorldDraft, +} from '../shared/scenario'; +import type { NationStats } from '../shared/types'; + +// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per +// country), standing in for server/regions.expandCountriesToKeys in these +// pure-function tests. +const fakeExpand = (countries: string[]): string[] => + countries.flatMap((c) => [`${c}-a`, `${c}-b`]); + +const S = (m: number, e: number, s: number, p: number): NationStats => ({ + military: m, + economy: e, + stability: s, + population: p, +}); + +describe('relationFromScore (v0.31.0, #52)', () => { + it('maps the five sentiment bands to RelationStatus', () => { + expect(relationFromScore(100)).toBe('ally'); + expect(relationFromScore(60)).toBe('ally'); + expect(relationFromScore(0)).toBe('neutral'); + expect(relationFromScore(30)).toBe('neutral'); + expect(relationFromScore(-1)).toBe('rival'); + expect(relationFromScore(-40)).toBe('rival'); + expect(relationFromScore(-41)).toBe('hostile'); + expect(relationFromScore(-80)).toBe('hostile'); + expect(relationFromScore(-81)).toBe('war'); + expect(relationFromScore(-100)).toBe('war'); + }); + + it('clamps out-of-range scores before banding', () => { + expect(relationFromScore(250)).toBe('ally'); + expect(relationFromScore(-250)).toBe('war'); + }); +}); + +describe('validateWorldDraft (v0.31.0, #52)', () => { + it('returns a clean draft from a valid payload', () => { + const raw = { + nations: [ + { id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } }, + { id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } }, + ], + }; + const d = validateWorldDraft(raw); + expect(d).not.toBeNull(); + const draft = d as WorldDraft; + expect(draft.nations).toHaveLength(2); + expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' }); + expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90)); + expect(draft.nations[0].territory).toEqual(['DEU']); + expect(draft.nations[0].relations).toEqual({ uk: 80 }); + expect(draft.events).toBeUndefined(); + }); + + it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => { + const raw = { + nations: [ + { name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50 + }); + + it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => { + const codes = new Set(['DEU', 'GBR', 'FRA']); + const raw = { + nations: [ + { + name: 'Mixed', + // lower-case, duplicated, and one unknown code (USA) to be dropped. + territory: ['deu', 'DEU', 'usa', 'gbr'], + }, + ], + }; + const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft; + expect(d.nations[0].territory).toEqual(['DEU', 'GBR']); + }); + + it('keeps unknown codes when no validCountryCodes set is supplied', () => { + const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].territory).toEqual(['AAA', 'BBB']); + }); + + it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => { + const raw = { + nations: [ + { id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } }, + { id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + // de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped. + expect(d.nations[0].relations).toEqual({ uk: 55 }); + // uk: the id-keyed ref to de is kept, clamped (already in range). + expect(d.nations[1].relations).toEqual({ de: -70 }); + }); + + it('clamps relation scores to -100..100', () => { + const raw = { + nations: [ + { id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } }, + { id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].relations).toEqual({ b: 100 }); + expect(d.nations[1].relations).toEqual({ a: -100 }); + }); + + it('suffices duplicate ids until unique and derives ids from names', () => { + const raw = { + nations: [ + { id: 'x', name: 'First', territory: ['AAA'] }, + { id: 'x', name: 'Second', territory: ['BBB'] }, + { name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + const ids = d.nations.map((n) => n.id); + expect(ids).toEqual(['x', 'x-2', 'united-kingdom']); + }); + + it('keeps backstory events that name a known nation and drops the rest', () => { + const raw = { + nations: [ + { id: 'de', name: 'Germany', territory: ['DEU'] }, + { id: 'uk', name: 'United Kingdom', territory: ['GBR'] }, + ], + events: [ + { nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' }, + { nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' }, + { nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' }, + { nation: 'de', type: 'political' }, // no title -> dropped + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.events).toHaveLength(2); + expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' }); + expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' }); + }); + + it('returns null (never throws) for unusable / garbage payloads', () => { + expect(validateWorldDraft(null)).toBeNull(); + expect(validateWorldDraft('nope')).toBeNull(); + expect(validateWorldDraft([])).toBeNull(); + expect(validateWorldDraft({})).toBeNull(); + expect(validateWorldDraft({ nations: [] })).toBeNull(); + expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull(); + expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name + expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object + // no nation owns a *valid* territory -> unusable. + expect( + validateWorldDraft( + { nations: [{ name: 'X', territory: ['ZZZ'] }] }, + { validCountryCodes: new Set(['DEU']) }, + ), + ).toBeNull(); + }); +}); + +describe('worldDraftToGameState (v0.31.0, #52)', () => { + function draft(): WorldDraft { + return { + nations: [ + { id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } }, + { id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } }, + ], + events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }], + }; + } + + it('expands ISO_A3 territory to region keys and seeds per-region stats', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'uk', { + id: 'g1', + title: 'Custom World', + startDate: '1939-09-01', + }); + expect(g.id).toBe('g1'); + expect(g.presetTitle).toBe('Custom World'); + expect(g.date).toBe('1939-09-01'); + expect(g.turn).toBe(1); + expect(g.version).toBe(1); + expect(g.playerNationId).toBe('uk'); + expect(g.unowned).toEqual([]); + expect(g.markers).toEqual([]); + expect(g.pendingNationOps).toEqual([]); + expect(g.pending).toEqual([]); + + // de owns DEU -> two region keys; uk owns GBR+IRL -> four. + expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']); + expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']); + + // Every owned region is seeded with a copy of its owner's stats. + expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats); + expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats); + // No entry for unowned regions. + expect(g.regionStats['ZZZ-a']).toBeUndefined(); + }); + + it('maps numeric relations to RelationStatus', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de'); + expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally + expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war + }); + + it('seeds the event feed from backstory events at turn 1 / startDate', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' }); + expect(g.events).toHaveLength(1); + expect(g.events[0]).toMatchObject({ + turn: 1, + date: '2035-01-01', + nation: 'de', + type: 'political', + title: 'The new order', + outcome: 'neutral', + }); + }); + + it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de'); + expect(g.id).toBe(''); + expect(g.presetTitle).toBe('Custom World'); + expect(g.date).toBe('1970-01-01'); + expect(g.presetId).toBe('dynamic'); + }); +}); diff --git a/server/scenario.test.ts b/server/scenario.test.ts new file mode 100644 index 0000000..76507e5 --- /dev/null +++ b/server/scenario.test.ts @@ -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); + }); +}); diff --git a/server/scenario.ts b/server/scenario.ts new file mode 100644 index 0000000..16f64cc --- /dev/null +++ b/server/scenario.ts @@ -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 }); +} diff --git a/server/stubScenario.ts b/server/stubScenario.ts new file mode 100644 index 0000000..0ff36d0 --- /dev/null +++ b/server/stubScenario.ts @@ -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() }); +} diff --git a/server/templates.test.ts b/server/templates.test.ts new file mode 100644 index 0000000..4fc68fb --- /dev/null +++ b/server/templates.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createTemplateStore } from './templates'; + +// The project root is the parent of server/ (mirrors how server/index.ts and +// server/regions.test.ts locate it via import.meta.url). +const root = fileURLToPath(new URL('..', import.meta.url)); +const templatesDir = path.join(root, 'presets', 'templates'); + +const store = createTemplateStore(templatesDir); + +/** A well-formed ISO_A3 country code: exactly three uppercase letters. */ +function isIsoA3(code: string): boolean { + return /^[A-Z]{3}$/.test(code); +} + +describe('era templates store (v0.31.0, #52)', () => { + it('the manifest lists exactly the three era templates', () => { + const meta = store.listMeta(); + expect(meta).toHaveLength(3); + const ids = meta.map((m) => m.id); + expect(ids).toEqual(['ww2', 'present', 'near-future']); + for (const m of meta) { + expect(m.id).toBeTruthy(); + expect(m.name).toBeTruthy(); + expect(typeof m.year).toBe('number'); + expect(m.eraDescription).toBeTruthy(); + } + }); + + it('each template file loads and is well-formed', () => { + for (const m of store.listMeta()) { + const t = store.get(m.id); + expect(t, `template ${m.id} should load`).not.toBeNull(); + const tpl = t as NonNullable<typeof t>; + expect(tpl.id).toBe(m.id); + expect(tpl.name).toBe(m.name); + expect(tpl.year).toBe(m.year); + expect(tpl.eraDescription).toBeTruthy(); + // suggested powers: non-empty, each a named power with an ISO_A3 core region + note. + expect(tpl.suggestedPowers.length).toBeGreaterThan(0); + for (const p of tpl.suggestedPowers) { + expect(p.name).toBeTruthy(); + expect(isIsoA3(p.region)).toBe(true); + expect(p.note).toBeTruthy(); + } + // tensions: a non-empty list of non-empty strings. + expect(tpl.tensions.length).toBeGreaterThan(0); + for (const s of tpl.tensions) expect(typeof s).toBe('string'); + for (const s of tpl.tensions) expect(s.trim()).not.toBe(''); + } + }); + + it('get returns null for an unknown id', () => { + expect(store.get('does-not-exist')).toBeNull(); + expect(store.get('')).toBeNull(); + }); +}); diff --git a/server/templates.ts b/server/templates.ts new file mode 100644 index 0000000..fbe6cec --- /dev/null +++ b/server/templates.ts @@ -0,0 +1,28 @@ +// Load era templates for the "Create scenario" path from disk (v0.31.0, #52). +// Mirrors the preset store (server/presets.ts): each template is a JSON file +// (presets/templates/<id>.json) and index.json is the lightweight manifest used +// to render the picker cards. The store is created with the resolved templates +// directory so it works from repo root (dev) and /app (Docker) alike. +import { readFileSync } from 'node:fs'; +import type { EraTemplate, TemplateMeta } from '../shared/scenario'; + +export interface TemplateStore { + listMeta(): TemplateMeta[]; + get(id: string): EraTemplate | null; +} + +export function createTemplateStore(dir: string): TemplateStore { + const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[]; + return { + listMeta() { + return manifest; + }, + get(id: string) { + try { + return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate; + } catch { + return null; + } + }, + }; +} diff --git a/shared/scenario.ts b/shared/scenario.ts new file mode 100644 index 0000000..f249515 --- /dev/null +++ b/shared/scenario.ts @@ -0,0 +1,458 @@ +// Dynamic scenario generation — the shared (dependency-free) contract. +// +// This is the additive "Create scenario" path: instead of picking one of the +// static presets, the user picks a light **era template**, writes a free-text +// description, and lets the LLM generate a fresh world (a {@link WorldDraft}) +// plus an advisor **checkpoint** list. The player reviews the checkpoints, +// approves/removes some, and picks a nation to lead; a second LLM call then +// finalizes the draft into a playable {@link GameState} (a normal game). +// +// Everything here is pure and has no runtime dependencies beyond the other +// shared modules: it may import types from `./types` and helpers from +// `./protocol`, but it never imports the engine, the LLM client, or any +// external package. The two LLM calls (`generateDraft` / `finalizeWorld`) +// live in `server/scenario.ts`; only the *shapes* and the pure transforms +// live here. + +import type { + EventEntry, + EventOutcome, + EventType, + GameState, + Nation, + NationStats, + NationType, + RelationStatus, +} from './types'; +import { clampStat, makeEvent, seedRegionStats, type TurnEventSpec } from './protocol'; + +// --- Era templates ----------------------------------------------------------- + +/** + * A suggested starting power for an era template: a display `name`, an ISO_A3 + * core `region` (the country the power is rooted in), and a short `note` the + * LLM is shown when it designs the world. + */ +export interface SuggestedPower { + name: string; + /** ISO_A3 country code the power's core territory sits in. */ + region: string; + note: string; +} + +/** + * A light **era template** the user can start from on the "Create scenario" + * path (one JSON file per template in `presets/templates/`, plus an + * `index.json` manifest). It is deliberately small: a name, a default `year`, + * a short `eraDescription`, a few `suggestedPowers`, and a few `tensions` — + * a seed the LLM generator expands into a full {@link WorldDraft}. + */ +export interface EraTemplate { + id: string; + name: string; + /** Default starting year (the user may override it in the form). */ + year: number; + /** A short description of the era, shown on the card and fed to the LLM. */ + eraDescription: string; + /** A few suggested starting powers (name + ISO_A3 core region + a note). */ + suggestedPowers: SuggestedPower[]; + /** A few suggested tensions / conflicts to seed the world. */ + tensions: string[]; +} + +/** The lightweight manifest entry for an era template (the picker card). */ +export interface TemplateMeta { + id: string; + name: string; + year: number; + eraDescription: string; +} + +// --- World draft ------------------------------------------------------------- + +/** + * A single nation in a generated world **draft** (before it is committed to a + * playable {@link GameState}). Its `territory` holds **ISO_A3 country codes** + * (not region keys) — the engine expands them to sub-region keys at commit + * time via the injected `expand` resolver. `relations` are the LLM's + * numeric sentiment toward other nations (−100..100); the engine maps them to + * a {@link RelationStatus} on commit. + */ +export interface WorldNation { + id: string; + name: string; + type?: NationType; + stats: NationStats; + /** ISO_A3 country codes the nation controls. */ + territory: string[]; + /** Bilateral sentiment: other nation id -> −100..100. */ + relations: Record<string, number>; +} + +/** An optional narrative backstory event carried by a world draft (turn 0). */ +export interface ScenarioEvent { + nation: string; + type: EventType; + title: string; + text: string; + outcome: EventOutcome; +} + +/** + * A draft world the LLM generator returns before the player confirms it. + * `nations` is required and non-empty; `events` is optional backstory that + * seeds the new game's event feed on commit. + */ +export interface WorldDraft { + nations: WorldNation[]; + events?: ScenarioEvent[]; +} + +/** + * A single **advisor checkpoint** — a short string the generator proposes for + * the player to approve (or remove) before the world is finalized. The + * approved checkpoints are handed to the finalize call as constraints. + */ +export type Checkpoint = string; + +// --- Client ↔ server message shapes (the two LLM steps) ----------------------- + +/** The body of `POST /api/scenarios/draft` (step 1: design the world). */ +export interface ScenarioDraftRequest { + templateId: string; + description: string; + /** The chosen start year (the server falls back to the template's year). */ + year?: number; +} + +/** The response of `POST /api/scenarios/draft` — the draft world + checkpoints. */ +export interface ScenarioDraftResponse { + world: WorldDraft; + checkpoints: string[]; +} + +/** The body of `POST /api/scenarios/commit` (step 2: finalize + save the game). */ +export interface ScenarioCommitRequest { + templateId: string; + description: string; + /** The chosen start year (the server falls back to the template's year). */ + year?: number; + /** The step-1 draft, re-sent so the final world stays close to it. */ + draft?: WorldDraft; + /** The checkpoints the player approved (the finalize step's constraints). */ + approvedCheckpoints: string[]; + /** The nation id the player will lead (kept in the final world when present). */ + playerNationId: string; +} + +/** The response of `POST /api/scenarios/commit` — the saved game's id. */ +export interface ScenarioCommitResponse { + id: string; +} + +// --- Validation + transforms (pure) ------------------------------------------ + +/** The 8 known event types (a local copy so this module needs no runtime import). */ +const EVENT_TYPES: readonly string[] = [ + 'military', + 'diplomatic', + 'economic', + 'political', + 'social', + 'disaster', + 'scientific', + 'cultural', +]; + +/** The 5 known nation types. */ +const NATION_TYPES: readonly string[] = ['major', 'minor', 'power', 'alliance', 'faction']; + +/** True when `t` is one of the eight known event types. */ +function isEventType(t: unknown): t is EventType { + return typeof t === 'string' && (EVENT_TYPES as string[]).includes(t); +} + +/** True when `t` is one of the five known event outcomes. */ +function isEventOutcome(o: unknown): o is EventOutcome { + return o === 'success' || o === 'failure' || o === 'partial' || o === 'neutral'; +} + +/** True when `t` is one of the five known nation types. */ +function isNationType(t: unknown): t is NationType { + return typeof t === 'string' && (NATION_TYPES as string[]).includes(t); +} + +/** Clamp a number to [lo, hi] and round (non-finite → lo). */ +function clampRange(v: number, lo: number, hi: number): number { + if (!Number.isFinite(v)) return lo; + return Math.max(lo, Math.min(hi, Math.round(v))); +} + +/** Parse a raw (unknown) stats object into a clamped NationStats (missing → 50). */ +function parseStats(raw: unknown): NationStats { + const o = + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record<string, unknown>) + : {}; + const get = (k: keyof NationStats): number => { + const v = o[k]; + return typeof v === 'number' && Number.isFinite(v) ? clampStat(v) : 50; + }; + return { + military: get('military'), + economy: get('economy'), + stability: get('stability'), + population: get('population'), + }; +} + +/** + * A short, stable, lowercase slug for a nation name (used as a fallback id): + * "United Kingdom" → "united-kingdom". Diacritics stripped, runs of + * non-alphanumerics collapsed to a single dash, length capped at 20. + */ +function slugFromName(name: string): string { + const s = name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!s) return ''; + return s.length > 20 ? s.slice(0, 20).replace(/-+$/, '') : s; +} + +/** + * Map a numeric sentiment score (−100..100) to a {@link RelationStatus}. + * Five bands: ally (≥60), neutral (0..59), rival (−40..−1), hostile (−80..−41), + * war (≤−81). Pure. + */ +export function relationFromScore(v: number): RelationStatus { + const c = clampRange(v, -100, 100); + if (c >= 60) return 'ally'; + if (c >= 0) return 'neutral'; + if (c >= -40) return 'rival'; + if (c >= -80) return 'hostile'; + return 'war'; +} + +/** + * Validate + normalize a raw LLM world-draft payload into a clean + * {@link WorldDraft} (or `null` when it is unusable). Never throws: + * - the top level must be an object with a non-empty `nations` array; + * - each nation needs a non-empty `name`; its `id` is kept when a valid + * non-empty string, else derived from a name slug (else `n<index>`); + * duplicate ids are suffixed until unique; + * - each nation's `type`/stats are clamped (missing stats → 50); + * - each nation's `territory` is upper-cased, deduped, and (when + * `validCountryCodes` is given) filtered to only known ISO_A3 codes; + * - a nation's `relations` are clamped to −100..100 and re-keyed to the + * assigned ids (a key that is a known id, or a case-insensitive name + * match, is kept; unknown / self references are dropped); + * - optional `events` are kept when they name a known nation (by id or + * name) and have a non-empty title; + * - the result is `null` when there are no nations, or no nation ends up + * owning a valid territory (nothing to render on the map). + */ +export function validateWorldDraft( + raw: unknown, + opts: { validCountryCodes?: ReadonlySet<string> } = {}, +): WorldDraft | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const obj = raw as Record<string, unknown>; + const rawNations = Array.isArray(obj.nations) ? obj.nations : null; + if (!rawNations || rawNations.length === 0) return null; + + const validCodes = opts.validCountryCodes; + const usedIds = new Set<string>(); + const nameToId = new Map<string, string>(); + + interface Tmp { + id: string; + name: string; + type: NationType; + stats: NationStats; + territory: string[]; + rawRelations: Record<string, number>; + } + const tmp: Tmp[] = []; + + for (const rn of rawNations) { + if (!rn || typeof rn !== 'object') continue; + const o = rn as Record<string, unknown>; + const name = typeof o.name === 'string' ? o.name.trim() : ''; + if (name === '') continue; // a nation needs a name + + // id: keep a valid non-empty string, else derive from the name slug, else n<index>. + let id = typeof o.id === 'string' && o.id.trim() !== '' ? o.id.trim() : ''; + if (id === '') id = slugFromName(name) || `n${tmp.length + 1}`; + let candidate = id; + let k = 2; + while (usedIds.has(candidate)) { + candidate = `${id}-${k}`; + k += 1; + } + id = candidate; + usedIds.add(id); + const lowerName = name.toLowerCase(); + if (!nameToId.has(lowerName)) nameToId.set(lowerName, id); + + const type = isNationType(o.type) ? o.type : 'major'; + const stats = parseStats(o.stats); + + // territory: upper-case, de-duped, and (optionally) filtered to known codes. + const territory: string[] = []; + if (Array.isArray(o.territory)) { + for (const t of o.territory) { + if (typeof t !== 'string') continue; + const code = t.trim().toUpperCase(); + if (code === '') continue; + if (validCodes && !validCodes.has(code)) continue; // drop unknown country codes + if (!territory.includes(code)) territory.push(code); + } + } + + // relations: keep only finite numeric values (clamped later on re-key). + const rawRelations: Record<string, number> = {}; + if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) { + for (const [key, v] of Object.entries(o.relations as Record<string, unknown>)) { + if (typeof v === 'number' && Number.isFinite(v)) rawRelations[key] = v; + } + } + + tmp.push({ id, name, type, stats, territory, rawRelations }); + } + + if (tmp.length === 0) return null; + // Unusable when no nation owns a valid territory (there is nothing to render). + if (!tmp.some((t) => t.territory.length > 0)) return null; + + const idSet = new Set(tmp.map((t) => t.id)); + const nations: WorldNation[] = tmp.map((t) => { + const relations: Record<string, number> = {}; + for (const [key, val] of Object.entries(t.rawRelations)) { + // Resolve the key: a known id, else a case-insensitive name match. + let target = key; + if (!idSet.has(target)) { + const mapped = nameToId.get(key.toLowerCase()); + if (mapped) target = mapped; + } + if (target === t.id) continue; // drop self-references + if (!idSet.has(target)) continue; // drop references to unknown nations + relations[target] = clampRange(val, -100, 100); + } + return { id: t.id, name: t.name, type: t.type, stats: t.stats, territory: t.territory, relations }; + }); + + // Optional backstory events (kept when they name a known nation). + let events: ScenarioEvent[] | undefined; + if (Array.isArray(obj.events)) { + const evs: ScenarioEvent[] = []; + for (const re of obj.events) { + if (!re || typeof re !== 'object') continue; + const e = re as Record<string, unknown>; + const title = typeof e.title === 'string' ? e.title.trim() : ''; + if (title === '') continue; + const text = typeof e.text === 'string' ? e.text : ''; + const type = isEventType(e.type) ? e.type : 'political'; + const outcome = isEventOutcome(e.outcome) ? e.outcome : 'neutral'; + let nation = typeof e.nation === 'string' ? e.nation : ''; + if (nation !== '' && !idSet.has(nation)) { + nation = nameToId.get(nation.toLowerCase()) ?? ''; + } + if (nation === '' || !idSet.has(nation)) continue; // drop events with no known owner + evs.push({ nation, type, title, text, outcome }); + } + events = evs; + } + + return { nations, events }; +} + +/** Options for {@link worldDraftToGameState} (all optional, sensible defaults). */ +export interface WorldDraftToGameOptions { + /** The new game's id (the server sets `randomUUID()`; default `''`). */ + id?: string; + /** The scenario title (becomes `GameState.presetTitle`; default 'Custom World'). */ + title?: string; + /** The game's start date (`YYYY-MM-DD`, from the chosen year; default '1970-01-01'). */ + startDate?: string; +} + +/** + * Convert a validated {@link WorldDraft} into a fresh, playable {@link + * GameState} (a normal game at `#/game/<id>`). Mirrors `POST /api/games` + * exactly, except the draft's nations carry **ISO_A3** territory which is + * expanded to sub-region keys by the injected `expand` resolver, and + * `regionStats` is seeded from the expanded territories via + * {@link seedRegionStats} (so every owned region starts with its owner's + * stats and the derived aggregate equals them on turn 1). + * + * The result is a clean turn-1 state: `version: 1`, `turn: 1`, `unowned: []`, + * `markers: []`, `pendingNationOps: []`, `pending: []`, the given + * `playerNationId`, and a `startDate` from the chosen year. Backstory events + * (if any) seed the event feed at turn 1. Pure — no side effects, no I/O. + */ +export function worldDraftToGameState( + draft: WorldDraft, + expand: (countries: string[]) => string[], + playerNationId: string, + opts: WorldDraftToGameOptions = {}, +): GameState { + const id = opts.id ?? ''; + const title = opts.title ?? 'Custom World'; + const startDate = opts.startDate ?? '1970-01-01'; + + const nations: Record<string, Nation> = {}; + for (const wn of draft.nations) { + const relations: Record<string, RelationStatus> = {}; + for (const [otherId, score] of Object.entries(wn.relations)) { + if (otherId === wn.id) continue; // no self-relations + relations[otherId] = relationFromScore(score); + } + nations[wn.id] = { + id: wn.id, + name: wn.name, + type: wn.type ?? 'major', + stats: wn.stats, + territory: expand(wn.territory), + relations, + }; + } + + // 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), + ); + + const state: GameState = { + id, + presetId: 'dynamic', + presetTitle: title, + playerNationId: effectivePlayer, + turn: 1, + date: startDate, + nations, + events, + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + // Seed AFTER the territories hold real region keys: each owned region gets + // a copy of its owner's stats, so the derived aggregate equals them. + regionStats: seedRegionStats(nations), + }; + return state; +} diff --git a/src/App.tsx b/src/App.tsx index f779122..cab5da4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { cn } from './lib/utils'; import PresetPicker from './components/PresetPicker'; import NationSelect from './components/NationSelect'; +import ScenarioForm from './components/ScenarioForm'; +import CheckpointReview from './components/CheckpointReview'; import WorldMap from './components/WorldMap'; import FloatingWindow from './components/FloatingWindow'; import MapLegend from './components/MapLegend'; @@ -26,9 +28,27 @@ import { api } from './lib/api'; import { useSse } from './lib/sse'; import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol'; import type { GameState, Preset, PresetMeta } from '@shared/types'; +import type { TemplateMeta, WorldDraft } from '@shared/scenario'; -/** Which top-level screen the app is showing. */ -type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' }; +/** + * Which top-level screen the app is showing. The dynamic path (v0.31, #52) adds + * two: 'scenario-form' (era + description + year) and 'scenario-review' (the + * generated world + checkpoint checklist + nation pick), the latter carrying + * the step-1 draft + chosen template so the finalize step can re-send them. + */ +type Screen = + | { name: 'home' } + | { name: 'pick'; preset: Preset } + | { name: 'game' } + | { name: 'scenario-form' } + | { + name: 'scenario-review'; + template: TemplateMeta; + description: string; + year: number; + world: WorldDraft; + checkpoints: string[]; + }; /** * Surface a turn failure. A 502 (live model down / timeout) gets a friendly, @@ -181,6 +201,10 @@ export default function App() { // non-LLM) request. It gates only the nation-op dialog controls, never the // turn flow — "Jump forward" stays usable while it is in flight. const [opBusy, setOpBusy] = useState(false); + // (v0.31, #52) A scenario-generation LLM call (generate or finalize) is + // in flight. Distinct from `busy`/`advisorBusy` — it only gates the + // scenario-form/review screens (those screens never coexist with a game). + const [scenarioBusy, setScenarioBusy] = useState(false); // The id of the game the user is/was last in (mirrors localStorage) and the // list of saved games (newest first) shown on the home screen. const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId()); @@ -359,6 +383,77 @@ export default function App() { [screen], ); + // --- (v0.31, #52) Dynamic scenario generation: form → generate → review → + // finalize → game. Both LLM steps run on the server (the scenario stub under + // LLM_STUB=1, the live model otherwise); on failure we toast and stay on the + // current screen (the form's / review's local selections are preserved). + + // Home screen's "Create scenario" card → the scenario form. + const openScenarioForm = useCallback(() => { + setScreen({ name: 'scenario-form' }); + }, []); + + // Step 1 (generate): design the draft world + checkpoint list. On success + // the app moves to the review screen (carrying the draft, template, year, + // and description so the finalize step can re-send them). + const generateScenarioDraft = useCallback( + async (template: TemplateMeta, description: string, year: number) => { + setScenarioBusy(true); + try { + const { world, checkpoints } = await api.draftScenario({ + templateId: template.id, + description, + year, + }); + setScreen({ name: 'scenario-review', template, description, year, world, checkpoints }); + } catch (e) { + toastError(e); + } finally { + setScenarioBusy(false); + } + }, + [], + ); + + // Step 2 (finalize): commit the approved draft into a saved, playable game + // and enter it. The step-1 draft is re-sent so the final world stays close + // to it (and the chosen nation survives); the new game's id is then fetched + // and adopted exactly like a normal created game (active-game persistence, + // applyState, screen → game — the URL-sync effect writes the deep link). + const finalizeScenario = useCallback( + async ( + template: TemplateMeta, + description: string, + year: number, + world: WorldDraft, + approvedCheckpoints: string[], + playerNationId: string, + ) => { + setScenarioBusy(true); + try { + const { id } = await api.commitScenario({ + templateId: template.id, + description, + year, + draft: world, + approvedCheckpoints, + playerNationId, + }); + const { game } = await api.getGame(id); + persistActiveGameId(id); + setActiveGameId(id); + applyState(game); + setAdvisors([]); + setScreen({ name: 'game' }); + } catch (e) { + toastError(e); + } finally { + setScenarioBusy(false); + } + }, + [applyState], + ); + // Add an action to the pending round. Fast, non-LLM — no `busy`; the pending // list updates from the response and the SSE `state` event. Both are // version-gated via applyState, so the item can never be clobbered by a @@ -573,6 +668,36 @@ export default function App() { ); } + if (screen.name === 'scenario-form') { + return ( + <> + <Toaster richColors position="top-right" /> + <ScenarioForm busy={scenarioBusy} onBack={goHome} onGenerate={generateScenarioDraft} /> + </> + ); + } + + if (screen.name === 'scenario-review') { + const { template, description, year, world, checkpoints } = screen; + return ( + <> + <Toaster richColors position="top-right" /> + <CheckpointReview + template={template} + description={description} + year={year} + world={world} + checkpoints={checkpoints} + busy={scenarioBusy} + onBack={openScenarioForm} + onFinalize={(approved, nationId) => + void finalizeScenario(template, description, year, world, approved, nationId) + } + /> + </> + ); + } + return ( <> <Toaster richColors position="top-right" /> @@ -584,6 +709,7 @@ export default function App() { activeGameId={activeGameId} onResume={resumeGame} onDelete={runDeleteGame} + onCreateScenario={openScenarioForm} /> </> ); diff --git a/src/components/CheckpointReview.tsx b/src/components/CheckpointReview.tsx new file mode 100644 index 0000000..55e3331 --- /dev/null +++ b/src/components/CheckpointReview.tsx @@ -0,0 +1,278 @@ +import { useState } from 'react'; +import { Check, ListChecks, Loader2, Sparkles } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import StatBar from './StatBar'; +import { cn } from '@/lib/utils'; +import { EVENT_COLORS, RELATION_COLORS } from '@/lib/colors'; +import { relationFromScore, type TemplateMeta, type WorldDraft, type WorldNation } from '@shared/scenario'; + +interface Props { + /** The era template the draft was generated from (name/year for the header). */ + template: TemplateMeta; + /** The free-text description the user wrote (shown as the world's context). */ + description: string; + /** The effective start year (template default or the user's override). */ + year: number; + /** The step-1 draft world to review and finalize. */ + world: WorldDraft; + /** The advisor's proposed checkpoints (all approved by default). */ + checkpoints: string[]; + /** A finalize (LLM) call is in flight — disables the controls. */ + busy?: boolean; + /** Back to the scenario form (a fresh attempt — the form's state resets). */ + onBack: () => void; + /** Step 2: finalize with the approved checkpoints + the chosen nation to lead. */ + onFinalize: (approvedCheckpoints: string[], playerNationId: string) => void; +} + +const STAT_BARS: Array<{ key: keyof WorldNation['stats']; label: string }> = [ + { key: 'military', label: 'Military' }, + { key: 'economy', label: 'Economy' }, + { key: 'stability', label: 'Stability' }, + { key: 'population', label: 'Population' }, +]; + +/** + * "Create scenario" — step 2 of the dynamic path (v0.31, #52). Shows the + * generated world (each nation's stats, territory country codes, and + * relations; plus any backstory events) and the advisor's proposed + * checkpoints as an approve/remove checklist (approved by default — the + * approved ones bind the finalize step). The player then picks which nation + * to lead (the nation cards mirror NationSelect's pick screen; the draft's + * nations are not a static Preset, so they are rendered here directly) and + * finalizes. On success the app navigates into the new game; on failure it + * toasts and stays here with the current selections intact. + */ +export default function CheckpointReview({ + template, + description, + year, + world, + checkpoints, + busy = false, + onBack, + onFinalize, +}: Props) { + // Approve/remove per checkpoint (approved by default — the advisor's list is + // a starting point the player prunes, not a form they fill in). + const [approved, setApproved] = useState<boolean[]>(() => checkpoints.map(() => true)); + // The nation the player will lead (none until explicitly picked). + const [selectedId, setSelectedId] = useState<string | null>(null); + + const nameOf = (id: string) => world.nations.find((n) => n.id === id)?.name ?? id; + const approvedCount = approved.filter(Boolean).length; + const selected = selectedId ? world.nations.find((n) => n.id === selectedId) : undefined; + + const toggle = (i: number) => + setApproved((prev) => prev.map((v, j) => (j === i ? !v : v))); + + const finalize = () => { + if (!selectedId) return; + onFinalize( + checkpoints.filter((_, i) => approved[i]), + selectedId, + ); + }; + + return ( + <div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-review"> + <Button variant="ghost" size="sm" onClick={onBack} className="mb-4"> + ← Back to form + </Button> + <div className="flex items-center gap-3"> + <h1 className="text-3xl font-bold tracking-tight">{template.name}</h1> + <Badge variant="secondary" className="text-sm"> + {year} + </Badge> + </div> + {description && ( + <p className="mt-2 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground"> + {description} + </p> + )} + + {/* --- The generated world --- */} + <h2 className="mb-3 mt-8 text-lg font-semibold">The generated world</h2> + <div className="space-y-3"> + {world.nations.map((n) => ( + <div + key={n.id} + className="rounded-lg border border-border/60 bg-card/40 p-3" + data-testid="draft-nation" + data-nation-id={n.id} + > + <div className="flex flex-wrap items-center gap-2"> + <span className="font-medium">{n.name}</span> + <Badge variant="secondary" className="text-xs capitalize"> + {n.type ?? 'major'} + </Badge> + {n.territory.length > 0 && ( + <span className="ml-auto text-xs text-muted-foreground"> + {n.territory.join(' · ')} + </span> + )} + </div> + <div className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2"> + {STAT_BARS.map(({ key, label }) => ( + <StatBar key={key} label={label} value={n.stats[key]} /> + ))} + </div> + {Object.keys(n.relations).length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1.5"> + {Object.entries(n.relations).map(([otherId, score]) => { + const status = relationFromScore(score); + return ( + <span + key={otherId} + className="rounded-full border px-2 py-0.5 text-[11px] font-medium" + style={{ + color: RELATION_COLORS[status], + borderColor: `${RELATION_COLORS[status]}55`, + }} + > + {nameOf(otherId)} {score > 0 ? `+${score}` : score} + </span> + ); + })} + </div> + )} + </div> + ))} + + {(world.events ?? []).length > 0 && ( + <div className="rounded-lg border border-border/60 bg-card/40 p-3"> + <div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> + Backstory + </div> + <ul className="space-y-2"> + {(world.events ?? []).map((e, i) => ( + <li key={i} className="flex items-start gap-2 text-sm" data-testid="draft-event"> + <span + className="mt-1.5 inline-block size-2 shrink-0 rounded-full" + style={{ background: EVENT_COLORS[e.type] }} + aria-hidden + /> + <span className="min-w-0 flex-1"> + <span className="font-medium">{nameOf(e.nation)}:</span>{' '} + {e.title} — {e.text} + </span> + </li> + ))} + </ul> + </div> + )} + </div> + + {/* --- Advisor checkpoints (approve / remove) --- */} + <div className="mt-8 flex items-center gap-2"> + <ListChecks className="size-4 text-primary" /> + <h2 className="text-lg font-semibold">Advisor checkpoints</h2> + <Badge variant="secondary" className="ml-auto text-xs tabular-nums"> + {approvedCount} of {checkpoints.length} approved + </Badge> + </div> + <p className="mb-3 mt-1 text-sm leading-relaxed text-muted-foreground"> + The advisor’s opening read of this world’s live strategic threads. Keep the ones the final + world should respect — removed checkpoints are dropped. + </p> + <ul className="space-y-1.5"> + {checkpoints.map((c, i) => ( + <li + key={`${i}-${c}`} + className={cn( + 'flex items-center gap-2 rounded-lg border p-2.5 text-sm', + approved[i] + ? 'border-amber-500/30 bg-amber-500/10' + : 'border-border/60 bg-card/30 text-muted-foreground', + )} + data-testid="checkpoint-item" + data-approved={approved[i] ? 'true' : 'false'} + > + <span + className={cn( + 'inline-flex size-4 shrink-0 items-center justify-center rounded-full border', + approved[i] ? 'border-amber-500/50 bg-amber-500/20' : 'border-border', + )} + aria-hidden + > + {approved[i] && <Check className="size-3 text-amber-400" />} + </span> + <span className="min-w-0 flex-1 leading-snug">{c}</span> + <Button + variant={approved[i] ? 'ghost' : 'secondary'} + size="sm" + onClick={() => toggle(i)} + disabled={busy} + data-testid="checkpoint-toggle" + > + {approved[i] ? 'Remove' : 'Keep'} + </Button> + </li> + ))} + </ul> + + {/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */} + <h2 className="mb-3 mt-8 text-lg font-semibold">Choose your nation</h2> + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + {world.nations.map((n) => { + const isSel = n.id === selectedId; + return ( + <Card + key={n.id} + className={cn( + 'flex flex-col transition', + isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40', + )} + data-testid="scenario-nation-card" + data-nation-id={n.id} + data-selected={isSel ? 'true' : 'false'} + > + <CardHeader> + <CardTitle className="flex items-center gap-2 text-base"> + {n.name} + {isSel && <Check className="size-4 shrink-0 text-primary" />} + </CardTitle> + <CardDescription> + {n.type ?? 'major'} · {n.territory.length} territory unit + {n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')}) + </CardDescription> + </CardHeader> + <CardContent className="mt-auto space-y-1.5"> + {STAT_BARS.map(({ key, label }) => ( + <StatBar key={key} label={label} value={n.stats[key]} /> + ))} + <Button + className="mt-3 w-full" + variant={isSel ? 'default' : 'secondary'} + onClick={() => setSelectedId(n.id)} + disabled={busy} + data-testid="scenario-lead-nation" + > + {isSel ? 'Leading' : `Play as ${n.name}`} + </Button> + </CardContent> + </Card> + ); + })} + </div> + + {/* --- Finalize --- */} + <div + className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3" + data-testid="finalize-bar" + > + <span className="min-w-0 flex-1 text-sm text-muted-foreground"> + {selected + ? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.` + : 'Pick the nation you will lead, then finalize the scenario.'} + </span> + <Button className="shrink-0" onClick={finalize} disabled={busy || !selected} data-testid="finalize-scenario"> + {busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />} + {busy ? 'Finalizing…' : 'Finalize scenario'} + </Button> + </div> + </div> + ); +} diff --git a/src/components/PresetPicker.tsx b/src/components/PresetPicker.tsx index 24f101d..75fd844 100644 --- a/src/components/PresetPicker.tsx +++ b/src/components/PresetPicker.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Play, Trash2 } from 'lucide-react'; +import { Play, Sparkles, Trash2 } from 'lucide-react'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; @@ -26,6 +26,12 @@ interface Props { onResume?: (id: string) => void; /** Delete a saved game by id (confirmed by a dialog in this component). */ onDelete?: (id: string) => void; + /** + * (v0.31, #52) Open the "Create scenario" path (era template + description → + * AI-generated world → checkpoint review → a normal game). Omitted = the + * create-scenario card is hidden. + */ + onCreateScenario?: () => void; } /** @@ -41,6 +47,7 @@ export default function PresetPicker({ activeGameId, onResume, onDelete, + onCreateScenario, }: Props) { const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined; // The game awaiting a delete confirmation (null = dialog closed). @@ -83,6 +90,51 @@ export default function PresetPicker({ )} <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> + {( + onCreateScenario + ? [ + { + // (v0.31, #52) The dynamic path, first in the grid: it is the + // "any world you can describe" card, distinct from the static presets. + id: 'create', + title: 'Create scenario', + summary: + 'Design your own world — pick an era, describe it, and the AI drafts the world and the advisor’s opening checkpoints for your review before you pick a nation to lead.', + badge: 'custom world', + }, + ] + : [] + ).map((c) => ( + <Card + key={c.id} + className="flex flex-col border-primary/40 bg-primary/5 transition hover:ring-1 hover:ring-primary/50" + data-testid="create-scenario-card" + > + <CardHeader> + <div className="flex items-start justify-between gap-2"> + <CardTitle className="flex items-center gap-2 text-lg"> + <Sparkles className="size-4 shrink-0 text-primary" /> + {c.title} + </CardTitle> + <Badge variant="secondary" className="shrink-0 text-xs"> + {c.badge} + </Badge> + </div> + <CardDescription className="text-sm leading-relaxed">{c.summary}</CardDescription> + </CardHeader> + <CardContent className="mt-auto"> + <Button + variant="secondary" + className="w-full" + onClick={() => onCreateScenario?.()} + disabled={loading} + data-testid="create-scenario" + > + Create scenario + </Button> + </CardContent> + </Card> + ))} {presets.map((p) => ( <Card key={p.id} diff --git a/src/components/ScenarioForm.tsx b/src/components/ScenarioForm.tsx new file mode 100644 index 0000000..a7d3699 --- /dev/null +++ b/src/components/ScenarioForm.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Sparkles } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Textarea } from './ui/textarea'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; +import type { TemplateMeta } from '@shared/scenario'; + +interface Props { + /** A generate (LLM) call is in flight — disables the form's controls. */ + busy?: boolean; + onBack: () => void; + /** + * Step 1: design the draft world + checkpoint list. The form hands over the + * selected template's full metadata (the review screen shows its name/year) + * plus the description and the effective start year. + */ + onGenerate: (template: TemplateMeta, description: string, year: number) => void; +} + +/** The year input is an empty string ("use the template's default") or a number. */ +const YEAR_MIN = 1000; +const YEAR_MAX = 3000; + +/** + * "Create scenario" — step 1 of the dynamic path (v0.31, #52). The user picks + * one of the light era templates (a card each: name, default year, blurb), + * writes a free-text description of the world they want, and may override the + * start year (the field is pre-filled from the selected template; leaving it + * empty keeps the template's default). "Generate world" posts the draft + * request; on success the app moves to the checkpoint review screen, on + * failure it toasts and stays here (the form's local state is preserved). + */ +export default function ScenarioForm({ busy = false, onBack, onGenerate }: Props) { + const [templates, setTemplates] = useState<TemplateMeta[]>([]); + const [templatesError, setTemplatesError] = useState<string | null>(null); + const [selectedId, setSelectedId] = useState<string | null>(null); + const [description, setDescription] = useState(''); + // Pre-filled from the selected template's default year (the user can edit). + const [yearText, setYearText] = useState(''); + + // Load the era-template manifest on mount (the form is the only consumer). + useEffect(() => { + let alive = true; + api + .listScenarioTemplates() + .then((r) => alive && setTemplates(r.templates)) + .catch((e: unknown) => alive && setTemplatesError(e instanceof Error ? e.message : String(e))); + return () => { + alive = false; + }; + }, []); + + const selected = templates.find((t) => t.id === selectedId) ?? null; + const effectiveYear = + yearText.trim() === '' ? NaN : Number.parseInt(yearText.trim(), 10); + const yearValid = + Number.isInteger(effectiveYear) && effectiveYear >= YEAR_MIN && effectiveYear <= YEAR_MAX; + + const canGenerate = !busy && selected !== null && description.trim() !== '' && yearValid; + + const selectTemplate = (t: TemplateMeta) => { + setSelectedId(t.id); + // Selecting an era pre-fills the year with that era's default (the user + // can still edit it afterwards). + setYearText(String(t.year)); + }; + + const generate = () => { + if (!canGenerate || !selected) return; + onGenerate(selected, description.trim(), effectiveYear); + }; + + return ( + <div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-form"> + <Button variant="ghost" size="sm" onClick={onBack} className="mb-4"> + ← Back to scenarios + </Button> + <h1 className="text-3xl font-bold tracking-tight">Create a scenario</h1> + <p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground"> + Design your own world: pick an era, describe what should be true, and the AI will draft the + world and an opening set of advisor checkpoints for your review. Nothing is saved until you + finalize. + </p> + + {/* 1 — the era template (a card each). */} + <h2 className="mb-3 mt-8 text-lg font-semibold"> + 1 · Pick an era <span className="font-normal text-muted-foreground">(required)</span> + </h2> + {templatesError ? ( + <p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"> + Could not load the era templates: {templatesError} + </p> + ) : templates.length === 0 ? ( + <p className="rounded-lg border border-border/60 bg-card/40 p-3 text-sm text-muted-foreground"> + Loading era templates… + </p> + ) : ( + <div className="grid gap-4 sm:grid-cols-3"> + {templates.map((t) => { + const isSel = t.id === selectedId; + return ( + <Card + key={t.id} + className={cn( + 'flex cursor-pointer flex-col transition', + isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40', + )} + data-testid="template-card" + data-template-id={t.id} + data-selected={isSel ? 'true' : 'false'} + onClick={() => selectTemplate(t)} + > + <CardHeader> + <div className="flex items-start justify-between gap-2"> + <CardTitle className="text-base">{t.name}</CardTitle> + <Badge variant="secondary" className="shrink-0 text-xs"> + {t.year} + </Badge> + </div> + <CardDescription className="text-sm leading-relaxed"> + {t.eraDescription} + </CardDescription> + </CardHeader> + <CardContent className="mt-auto"> + <div + className={cn( + 'flex items-center gap-2 text-xs', + isSel ? 'font-medium text-primary' : 'text-muted-foreground', + )} + > + <Sparkles className="size-3.5" /> + {isSel ? 'Selected' : 'Start from this era'} + </div> + </CardContent> + </Card> + ); + })} + </div> + )} + + {/* 2 — the description (+ optional year override). */} + <h2 className="mb-3 mt-8 text-lg font-semibold"> + 2 · Describe your world <span className="font-normal text-muted-foreground">(required)</span> + </h2> + <div className="space-y-4 rounded-lg border border-border/60 bg-card/40 p-4"> + <Textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="e.g. “A cold-war world where the space race decided the Cold War — two blocs orbit, and the arctic shipping lanes are the new flashpoint.”" + rows={4} + disabled={busy} + data-testid="scenario-description" + /> + <div className="flex items-center gap-3"> + <Label htmlFor="scenario-year-input" className="shrink-0 text-sm"> + Start year + </Label> + <Input + id="scenario-year-input" + type="number" + inputMode="numeric" + value={yearText} + onChange={(e) => setYearText(e.target.value)} + placeholder={selected ? `default ${selected.year}` : 'pick an era first'} + min={YEAR_MIN} + max={YEAR_MAX} + disabled={busy || !selected} + className="w-32" + data-testid="scenario-year" + /> + <span className="text-xs text-muted-foreground"> + {yearValid ? `The game begins on ${effectiveYear}-01-01.` : 'Leave empty for the era default, or use a year between 1000 and 3000.'} + </span> + </div> + </div> + + {/* Generate. */} + <div className="mt-8"> + <Button className="h-11 w-full" onClick={generate} disabled={!canGenerate} data-testid="generate-scenario"> + {busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />} + {busy ? 'Generating world…' : 'Generate world'} + </Button> + {busy && ( + <p className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground"> + <span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" /> + The world-builder is thinking — this can take a few seconds. + </p> + )} + </div> + </div> + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9d6bcb0..2d87cc8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,6 +14,13 @@ import type { RemoveQueueItemResponse, TurnResponse, } from '@shared/protocol'; +import type { + ScenarioCommitRequest, + ScenarioCommitResponse, + ScenarioDraftRequest, + ScenarioDraftResponse, + TemplateMeta, +} from '@shared/scenario'; async function req<T>(path: string, init?: RequestInit): Promise<T> { const res = await fetch(path, { @@ -95,4 +102,24 @@ export const api = { req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>( `/api/games/${id}/map`, ), + // --- Dynamic scenario generation (v0.31, #52) --------------------------- + // The additive "Create scenario" path: a two-step LLM flow (design a draft + // world + checkpoint list, then finalize it into a saved, playable game). + // The era-template manifest for the form's cards (id / name / year / blurb). + listScenarioTemplates: () => req<{ templates: TemplateMeta[] }>('/api/scenarios/templates'), + // Step 1: design a draft world + an advisor checkpoint list from an era + // template + a free-text description. An LLM call — can be slow; a 502 + // means the model is down or returned nothing usable (the caller toasts). + draftScenario: (body: ScenarioDraftRequest) => + req<ScenarioDraftResponse>('/api/scenarios/draft', { + method: 'POST', + body: JSON.stringify(body), + }), + // Step 2: finalize the approved draft (constrained to the approved + // checkpoints) into a new, playable game and save it. Returns the id. + commitScenario: (body: ScenarioCommitRequest) => + req<ScenarioCommitResponse>('/api/scenarios/commit', { + method: 'POST', + body: JSON.stringify(body), + }), };