Files
History/src/components/ScenarioForm.tsx
T
kamilkrzywda 396702b9da 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.
2026-10-15 10:21:53 +00:00

198 lines
8.2 KiB
TypeScript

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>
);
}