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.
249 lines
9.3 KiB
TypeScript
249 lines
9.3 KiB
TypeScript
import { useState } from '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';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from './ui/dialog';
|
||
import type { PresetMeta } from '@shared/types';
|
||
import type { GameSummary } from '@shared/protocol';
|
||
|
||
interface Props {
|
||
presets: PresetMeta[];
|
||
loading?: boolean;
|
||
onPlay: (presetId: string) => void;
|
||
/** Recent saved games (newest-updated first) for the "Saved games" section. */
|
||
savedGames?: GameSummary[];
|
||
/** The id remembered as the active game (drives the "Resume last game" button). */
|
||
activeGameId?: string | null;
|
||
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Home screen: a grid of scenario cards (each with a Play button), a prominent
|
||
* "Resume last game" affordance, and a "Saved games" section listing prior
|
||
* games so the user can get back into one — or delete it.
|
||
*/
|
||
export default function PresetPicker({
|
||
presets,
|
||
loading,
|
||
onPlay,
|
||
savedGames = [],
|
||
activeGameId,
|
||
onResume,
|
||
onDelete,
|
||
onCreateScenario,
|
||
}: Props) {
|
||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||
// The game awaiting a delete confirmation (null = dialog closed).
|
||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||
const confirmDeleteTarget = confirmDeleteId
|
||
? savedGames.find((g) => g.id === confirmDeleteId)
|
||
: undefined;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||
<header className="mb-8">
|
||
<h1 className="text-3xl font-bold tracking-tight">History</h1>
|
||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||
An AI-driven alternate-history simulator. Pick a scenario and a nation, then shape the
|
||
world turn by turn — the LLM is the world engine.
|
||
</p>
|
||
</header>
|
||
|
||
{/* Resume last game — prominent, shown whenever a game is remembered. */}
|
||
{activeGameId && onResume && (
|
||
<div
|
||
data-testid="resume-bar"
|
||
className="mb-8 rounded-xl border border-primary/30 bg-primary/5 p-3"
|
||
>
|
||
<Button
|
||
data-testid="resume-last"
|
||
className="w-full justify-between"
|
||
onClick={() => onResume(activeGameId)}
|
||
disabled={loading}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Play className="size-4" />
|
||
Resume last game
|
||
</span>
|
||
<span className="text-sm font-normal text-muted-foreground">
|
||
{activeGame ? `${activeGame.presetTitle} · ${activeGame.date}` : 'Pick up where you left off'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<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}
|
||
className="flex flex-col transition hover:ring-1 hover:ring-primary/40"
|
||
data-testid="preset-card"
|
||
>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<CardTitle className="text-lg">{p.title}</CardTitle>
|
||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||
{p.startDate}
|
||
</Badge>
|
||
</div>
|
||
<CardDescription className="text-sm leading-relaxed">{p.summary}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="mt-auto">
|
||
<Button className="w-full" onClick={() => onPlay(p.id)} disabled={loading}>
|
||
Play
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Saved games: get back into any prior game (or delete it). */}
|
||
{savedGames.length > 0 && (
|
||
<section className="mt-12" data-testid="saved-games">
|
||
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{savedGames.map((g) => (
|
||
<Card
|
||
key={g.id}
|
||
className="flex flex-col"
|
||
data-testid="saved-game"
|
||
data-game-id={g.id}
|
||
>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<Badge variant="secondary" className="text-xs">
|
||
{g.date}
|
||
</Badge>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground hover:text-destructive"
|
||
data-testid="delete-game"
|
||
aria-label={`Delete ${g.presetTitle}`}
|
||
title="Delete this game"
|
||
onClick={() => setConfirmDeleteId(g.id)}
|
||
disabled={loading}
|
||
>
|
||
<Trash2 />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="mt-auto">
|
||
<Button
|
||
variant="secondary"
|
||
className="w-full"
|
||
onClick={() => onResume?.(g.id)}
|
||
disabled={loading}
|
||
>
|
||
Resume
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Confirm before permanently deleting a saved game. */}
|
||
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
|
||
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
|
||
<DialogHeader>
|
||
<DialogTitle>Delete this game?</DialogTitle>
|
||
<DialogDescription>
|
||
{confirmDeleteTarget
|
||
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
|
||
: 'This game will be permanently removed.'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
data-testid="delete-game-cancel"
|
||
onClick={() => setConfirmDeleteId(null)}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
data-testid="delete-game-confirm"
|
||
onClick={() => {
|
||
const id = confirmDeleteId;
|
||
setConfirmDeleteId(null);
|
||
if (id) onDelete?.(id);
|
||
}}
|
||
>
|
||
Delete
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|