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.
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|