Files
History/src/components/PresetPicker.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

249 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}