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
+278
View File
@@ -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>
);
}
+53 -1
View File
@@ -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}
+197
View File
@@ -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>
);
}