From 396702b9da74759e0890a2cb6587a42d9b5ad78e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 10:21:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(#52):=20dynamic=20scenario=20client=20(for?= =?UTF-8?q?m=20=E2=86=92=20review=20=E2=86=92=20finalize)=20+=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive 'Create scenario' path (v0.31): the home screen's create-scenario card opens a ScenarioForm (pick one of 3 light era templates + a free-text description + an editable, era-prefilled start year) whose 'Generate world' posts the draft request; the CheckpointReview screen shows the generated world (per-nation stats/territory/relations + backstory) and the advisor's checkpoint list as an approve/remove checklist, then the player picks a nation to lead and finalizes into a saved, playable game. Both LLM steps ride the scenario client (deterministic stub under LLM_STUB=1); a failure toasts and stays on the current screen. The turn stub and static presets are untouched. New e2e covers the full deterministic flow (near-future 2035, remove checkpoint 3, lead Boreas, finalize, one empty jump) and the 3-template manifest. --- e2e/scenario.spec.ts | 253 +++++++++++++++++++++++++ shared/scenario.ts | 35 ++++ src/App.tsx | 130 ++++++++++++- src/components/CheckpointReview.tsx | 278 ++++++++++++++++++++++++++++ src/components/PresetPicker.tsx | 54 +++++- src/components/ScenarioForm.tsx | 197 ++++++++++++++++++++ src/lib/api.ts | 27 +++ 7 files changed, 971 insertions(+), 3 deletions(-) create mode 100644 e2e/scenario.spec.ts create mode 100644 src/components/CheckpointReview.tsx create mode 100644 src/components/ScenarioForm.tsx 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/shared/scenario.ts b/shared/scenario.ts index 062eabc..f249515 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -115,6 +115,41 @@ export interface WorldDraft { */ 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). */ 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(() => 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 ( + <> + + + + ); + } + + if (screen.name === 'scenario-review') { + const { template, description, year, world, checkpoints } = screen; + return ( + <> + + + void finalizeScenario(template, description, year, world, approved, nationId) + } + /> + + ); + } + return ( <> @@ -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(() => checkpoints.map(() => true)); + // The nation the player will lead (none until explicitly picked). + const [selectedId, setSelectedId] = useState(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 ( +
+ +
+

{template.name}

+ + {year} + +
+ {description && ( +

+ {description} +

+ )} + + {/* --- The generated world --- */} +

The generated world

+
+ {world.nations.map((n) => ( +
+
+ {n.name} + + {n.type ?? 'major'} + + {n.territory.length > 0 && ( + + {n.territory.join(' · ')} + + )} +
+
+ {STAT_BARS.map(({ key, label }) => ( + + ))} +
+ {Object.keys(n.relations).length > 0 && ( +
+ {Object.entries(n.relations).map(([otherId, score]) => { + const status = relationFromScore(score); + return ( + + {nameOf(otherId)} {score > 0 ? `+${score}` : score} + + ); + })} +
+ )} +
+ ))} + + {(world.events ?? []).length > 0 && ( +
+
+ Backstory +
+
    + {(world.events ?? []).map((e, i) => ( +
  • + + + {nameOf(e.nation)}:{' '} + {e.title} — {e.text} + +
  • + ))} +
+
+ )} +
+ + {/* --- Advisor checkpoints (approve / remove) --- */} +
+ +

Advisor checkpoints

+ + {approvedCount} of {checkpoints.length} approved + +
+

+ The advisor’s opening read of this world’s live strategic threads. Keep the ones the final + world should respect — removed checkpoints are dropped. +

+
    + {checkpoints.map((c, i) => ( +
  • + + {approved[i] && } + + {c} + +
  • + ))} +
+ + {/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */} +

Choose your nation

+
+ {world.nations.map((n) => { + const isSel = n.id === selectedId; + return ( + + + + {n.name} + {isSel && } + + + {n.type ?? 'major'} · {n.territory.length} territory unit + {n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')}) + + + + {STAT_BARS.map(({ key, label }) => ( + + ))} + + + + ); + })} +
+ + {/* --- Finalize --- */} +
+ + {selected + ? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.` + : 'Pick the nation you will lead, then finalize the scenario.'} + + +
+
+ ); +} 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({ )}
+ {( + 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) => ( + + +
+ + + {c.title} + + + {c.badge} + +
+ {c.summary} +
+ + + +
+ ))} {presets.map((p) => ( 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([]); + const [templatesError, setTemplatesError] = useState(null); + const [selectedId, setSelectedId] = useState(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 ( +
+ +

Create a scenario

+

+ 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. +

+ + {/* 1 — the era template (a card each). */} +

+ 1 · Pick an era (required) +

+ {templatesError ? ( +

+ Could not load the era templates: {templatesError} +

+ ) : templates.length === 0 ? ( +

+ Loading era templates… +

+ ) : ( +
+ {templates.map((t) => { + const isSel = t.id === selectedId; + return ( + selectTemplate(t)} + > + +
+ {t.name} + + {t.year} + +
+ + {t.eraDescription} + +
+ +
+ + {isSel ? 'Selected' : 'Start from this era'} +
+
+
+ ); + })} +
+ )} + + {/* 2 — the description (+ optional year override). */} +

+ 2 · Describe your world (required) +

+
+