Fix save/resume after refresh + map basemap
Map (basemap was rendering black): - Inline MapLibre style (dark-navy background + subtle OpenFreeMap ne2sr raster) instead of the remote "dark" style, whose vector tiles are empty at world zoom and gated the choropleth on a remote styledata fetch. - Explicit prod worker URL (maplibre-gl-worker via ?url) so GeoJSON sources process in the production bundle. - Hardened the choropleth against geo load-order races (empty sources up front, ownership stamped once data lands) and bumped fill opacity so the vivid per-nation colors are the star on the non-black background. Save/resume (a refresh used to drop users to the home screen): - Persist the active game id in localStorage (history.activeGameId) on create and resume; clear it when explicitly starting a new game. - Auto-resume the last game on a page refresh (GET /api/games/:id + /map), re-attaching the SSE stream; fall back to home gracefully if it's gone. - Home screen: prominent "Resume last game" button + a "Saved games" section (newest first). New GET /api/games list endpoint. e2e: two new deterministic tests — auto-resume-on-reload and explicit resume from the home screen (both under LLM_STUB=1). Quality gates: typecheck, lint, 21 unit tests, 3 e2e tests — all pass.
This commit is contained in:
@@ -1,16 +1,37 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
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;
|
||||
}
|
||||
|
||||
/** Home screen: a grid of scenario cards, each with a Play button. */
|
||||
export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function PresetPicker({
|
||||
presets,
|
||||
loading,
|
||||
onPlay,
|
||||
savedGames = [],
|
||||
activeGameId,
|
||||
onResume,
|
||||
}: Props) {
|
||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<header className="mb-8">
|
||||
@@ -20,6 +41,30 @@ export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
||||
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">
|
||||
{presets.map((p) => (
|
||||
<Card
|
||||
@@ -44,6 +89,42 @@ export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Saved games: get back into any prior game. */}
|
||||
{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"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{g.date}
|
||||
</Badge>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user