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:
2026-10-11 07:18:11 +00:00
parent 913d094e11
commit 2ed1b15318
6 changed files with 380 additions and 32 deletions
+83 -2
View File
@@ -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>
);
}
+103 -24
View File
@@ -1,6 +1,13 @@
import { useEffect, useRef } from 'react';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
// MapLibre v5+ does NOT auto-resolve its web worker in a Vite production bundle
// (it only worked in the dev server, which serves node_modules on demand). Without
// an explicit URL the worker fails to load and GeoJSON sources — including our
// country choropleth — can never process their data, so the map stays blank.
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl);
import type { RegionMap, RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
@@ -18,10 +25,49 @@ interface FeatureCollection {
}
type GeoSource = maplibregl.GeoJSONSource;
// Remote OpenFreeMap "dark" basemap — a near-black (rgb 12,12,12) canvas so the
// choropleth nation colors pop. (Verified HTTP 200; the suggested dark-matter /
// dark-outdoors / liberty-dark variants do not exist on that host.)
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
// Inline basemap style — a visible dark-navy background plus a subtle
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn
// on top and is the real star.
//
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
// are empty at world zoom, so only its very dark raster rendered and the map
// looked black, and `styledata` was gated on fetching a remote document. An
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
// raster has no vector tiles, so no glyphs endpoint is needed — we don't add
// label layers; the choropleth provides the borders.)
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
const INLINE_STYLE: maplibregl.StyleSpecification = {
version: 8,
sources: {
terrain: {
type: 'raster',
tiles: NE2SR_TILES,
tileSize: 256,
maxzoom: 6,
attribution: '© <a href="https://openfreemap.org">OpenFreeMap</a>',
},
},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#0b1220' },
},
{
id: 'terrain',
type: 'raster',
source: 'terrain',
paint: {
'raster-opacity': 0.5,
'raster-brightness-max': 0.8,
'raster-fade-duration': 200,
},
},
],
};
const COUNTRIES_SOURCE = 'countries';
const HOVER_SOURCE = 'hover';
@@ -151,8 +197,9 @@ interface Props {
/**
* World basemap (MapLibre GL) + a country choropleth colored by the owning
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
* floating HUD windows: a near-black basemap, per-nation stable fills, subtle
* borders, a brighter/thicker border for the player, hover + click popups with
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
* border for the player, hover + click popups with
* owner + stats, a transient flash on territory changes, and a
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
* zoom control can drive it (no MapLibre navigation controls are shown).
@@ -197,7 +244,7 @@ export default function WorldMap({
const cam = cameraForPreset(presetRef.current);
const map = new maplibregl.Map({
container: containerRef.current,
style: STYLE_URL,
style: INLINE_STYLE,
center: cam.center,
zoom: cam.zoom,
minZoom: 1.25,
@@ -205,6 +252,10 @@ export default function WorldMap({
attributionControl: { compact: true },
});
mapRef.current = map;
// Test/debug handle on the DOM node so a Playwright `page.evaluate` can reach
// the live MapLibre instance (the ref itself isn't reachable from the page).
// Purely for testability; has no effect on rendering or behavior.
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
// Expose a tiny zoom/pan API to the floating control (null on unmount).
const api: MapApi = {
@@ -315,22 +366,21 @@ export default function WorldMap({
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
};
// Idempotent: (re)adds the sources + layers after any setStyle and on first
// load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
const ensureLayers = async () => {
let geo: FeatureCollection;
try {
geo = await loadGeo();
} catch (err) {
console.error('failed to load countries geojson', err);
return;
}
if (cancelled) return;
// Idempotent: (re)creates the sources + layers after any style (re)load and
// (re)applies the ownership data. (Vite serves MapLibre's worker
// automatically, so no setWorkerUrl is needed.)
//
// The three choropleth sources are created up front (empty until the base
// geo arrives) so the layer structure always exists; `paintCountries` then
// stamps ownership + colors onto the countries source. This makes it immune
// to load-order races: if `ensureLayers` runs before the geo fetch
// resolves, the data is applied the moment the fetch lands; if the geo
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
// regions effect re-paints whenever ownership changes.
const ensureSources = () => {
if (!map.getSource(COUNTRIES_SOURCE)) {
map.addSource(COUNTRIES_SOURCE, {
type: 'geojson',
data: applyRegions(geo, regionsRef.current, playerRef.current),
});
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
}
if (!map.getSource(HOVER_SOURCE)) {
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
@@ -338,12 +388,38 @@ export default function WorldMap({
if (!map.getSource(FLASH_SOURCE)) {
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
}
};
// Stamp ownership + colors onto the countries source (no-op until geo loads).
const paintCountries = () => {
const geo = baseGeoRef.current;
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
if (!geo || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
};
const ensureLayers = async () => {
if (cancelled) return;
ensureSources();
if (baseGeoRef.current) {
paintCountries();
} else {
try {
await loadGeo();
} catch (err) {
console.error('failed to load countries geojson', err);
}
if (cancelled) return;
paintCountries();
}
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
map.addLayer({
id: FILL_LAYER,
type: 'fill',
source: COUNTRIES_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.8 },
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
});
}
if (!map.getLayer(LINE_LAYER)) {
@@ -351,7 +427,7 @@ export default function WorldMap({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.6, 'line-opacity': 0.9 },
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
});
}
// NOTE: layers are added in bottom→top order (each appends on top). The
@@ -409,6 +485,9 @@ export default function WorldMap({
onMapReadyRef.current?.(null);
map.remove();
mapRef.current = null;
if (containerRef.current) {
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = undefined;
}
};
}, []);