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.
198 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|