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:
2026-10-15 10:21:53 +00:00
parent 832957c635
commit 396702b9da
7 changed files with 971 additions and 3 deletions
+128 -2
View File
@@ -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}
/>
</>
);