feat(#52): dynamic scenario client (form → review → finalize) + e2e
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.
This commit is contained in:
@@ -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/<id>. */
|
||||
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<string, string>;
|
||||
}
|
||||
>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
+128
-2
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user