diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index 79b3a1c..f2ceb33 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; /** * Fully deterministic end-to-end test. The Playwright webServer boots the real @@ -85,3 +85,63 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => { await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success'); }); }); + +/** + * Save / resume across a page refresh. The active game's id is persisted in + * localStorage, so a reload (or the home screen's "Resume last game" button) + * must drop the user straight back into the same game — same turn, same stats, + * same map — rather than resetting to the home screen. Deterministic under the + * stub (UK military starts at 80 and +5 per turn). + */ +test.describe('History — save / resume across a page refresh', () => { + async function createUkGameAtTurn2(page: Page): Promise { + await page.goto('/'); + await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 }); + await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click(); + await page + .getByTestId('nation-card-uk') + .getByRole('button', { name: 'Play as United Kingdom' }) + .click(); + + // Game screen mounts at turn 1 (UK military starts at 80). + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('1'); + await expect(page.getByTestId('stat-military')).toHaveText('80'); + + // One action: the stub bumps UK military to 85 and advances to turn 2. + await page.getByTestId('action-textarea').fill('Reinforce the Atlantic convoys'); + await page.getByTestId('action-submit').click(); + await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); + await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 }); + } + + test('auto-resumes into the same game after a page reload', async ({ page }) => { + await createUkGameAtTurn2(page); + + // Reload: the persisted active game id must restore the game — not home. + await page.reload(); + + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); + await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 }); + // The player's identity is restored in the header… + await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom'); + // …and the map legend is regenerated from the persisted region map. + await expect(page.getByTestId('map-legend-item').first()).toBeVisible({ timeout: 15000 }); + }); + + test('explicitly resumes a saved game from the home screen', async ({ page }) => { + await createUkGameAtTurn2(page); + + // Back to the home screen: the "Resume last game" affordance is present. + await page.getByTestId('back-to-scenarios').click(); + await expect(page.getByTestId('resume-last')).toBeVisible(); + + // Resuming drops back into the same game, at the saved turn. + await page.getByTestId('resume-last').click(); + await expect(page.getByTestId('world-map')).toBeVisible(); + await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 }); + await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 }); + await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom'); + }); +}); diff --git a/server/index.ts b/server/index.ts index 9dfdd56..656fa67 100644 --- a/server/index.ts +++ b/server/index.ts @@ -118,6 +118,25 @@ app.post('/api/games', (req: Request, res) => { res.json({ game: state }); }); +// List every saved game (newest-updated first) as a lightweight summary, so the +// home screen can offer to resume prior games. `db.listIds()` is already ordered +// by updated_at DESC. +app.get('/api/games', (_req, res) => { + const games = db + .listIds() + .map((id) => db.load(id)) + .filter((g): g is GameState => g !== null) + .map((g) => ({ + id: g.id, + presetId: g.presetId, + presetTitle: g.presetTitle, + playerNationId: g.playerNationId, + turn: g.turn, + date: g.date, + })); + res.json({ games }); +}); + app.get('/api/games/:id', (req, res) => { const game = db.load(req.params.id); if (!game) return res.status(404).json({ error: 'game not found' }); diff --git a/src/App.tsx b/src/App.tsx index c07b0ab..5f394b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,7 +26,7 @@ import EventFeed from './components/EventFeed'; import ActionPanel from './components/ActionPanel'; import { api } from './lib/api'; import { useSse } from './lib/sse'; -import { nationColor, type RegionMap } from '@shared/protocol'; +import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol'; import type { GameState, Preset, PresetMeta } from '@shared/types'; /** Which top-level screen the app is showing. */ @@ -78,6 +78,39 @@ function persistCollapsedMap(m: Record): void { } } +// --------------------------------------------------------------------------- +// Active-game persistence. The id of the game the user is (or was last) playing +// is remembered in localStorage, so a page refresh drops them straight back +// into their game, and the home screen can offer to resume it explicitly. +// --------------------------------------------------------------------------- + +const ACTIVE_GAME_KEY = 'history.activeGameId'; + +function readActiveGameId(): string | null { + try { + const v = localStorage.getItem(ACTIVE_GAME_KEY); + return v && v.length > 0 ? v : null; + } catch { + return null; + } +} + +function persistActiveGameId(id: string): void { + try { + localStorage.setItem(ACTIVE_GAME_KEY, id); + } catch { + /* storage unavailable — the game just won't auto-resume */ + } +} + +function clearActiveGameId(): void { + try { + localStorage.removeItem(ACTIVE_GAME_KEY); + } catch { + /* storage unavailable */ + } +} + export default function App() { const [screen, setScreen] = useState({ name: 'home' }); const [presets, setPresets] = useState([]); @@ -86,8 +119,13 @@ export default function App() { const [regions, setRegions] = useState({}); const [advisors, setAdvisors] = useState([]); const [busy, setBusy] = useState(false); + // The id of the game the user is/was last in (mirrors localStorage) and the + // list of saved games (newest first) shown on the home screen. + const [activeGameId, setActiveGameId] = useState(() => readActiveGameId()); + const [savedGames, setSavedGames] = useState([]); - // Load the scenario manifest once, on the home screen. + // On the home screen: load the scenario manifest and the list of saved games + // (newest-updated first) so the user can resume a prior game. useEffect(() => { if (screen.name !== 'home') return; let alive = true; @@ -95,11 +133,44 @@ export default function App() { .listPresets() .then((r) => alive && setPresets(r.presets)) .catch(() => {}); + api + .listGames() + .then((r) => alive && setSavedGames(r.games)) + .catch(() => {}); return () => { alive = false; }; }, [screen.name]); + // On first mount, auto-resume the last active game (if any is stored): fetch + // its persisted state, remember it, and drop the user straight back into the + // game screen. The map is refetched by the effect below (keyed on `game`) and + // the `useSse(game?.id)` effect re-attaches the stream, resyncing from the + // server on connect. If the id is stale (404 / deleted), clear it and fall + // back to the home screen gracefully. + useEffect(() => { + const id = readActiveGameId(); + if (!id) return; + let alive = true; + (async () => { + try { + const { game } = await api.getGame(id); + if (!alive) return; + setActiveGameId(id); + setGame(game); + setScreen({ name: 'game' }); + } catch { + if (alive) { + clearActiveGameId(); + setActiveGameId(null); + } + } + })(); + return () => { + alive = false; + }; + }, []); + // Keep the choropleth in sync with whatever state is shown (territory changes). useEffect(() => { if (!game) return; @@ -118,6 +189,10 @@ export default function App() { const play = useCallback(async (presetId: string) => { setLoading(true); + // Explicitly starting a fresh game: drop the remembered active game so a + // refresh won't resurrect the previous one (`pickNation` sets the new id). + clearActiveGameId(); + setActiveGameId(null); try { const { preset } = await api.getPreset(presetId); setScreen({ name: 'pick', preset }); @@ -135,6 +210,9 @@ export default function App() { setLoading(true); try { const { game } = await api.createGame(screen.preset.id, nationId); + // Remember this as the active game so a refresh resumes it. + persistActiveGameId(game.id); + setActiveGameId(game.id); setGame(game); setAdvisors([]); setScreen({ name: 'game' }); @@ -205,6 +283,27 @@ export default function App() { [game], ); + const resumeGame = useCallback(async (id: string) => { + setLoading(true); + try { + const { game } = await api.getGame(id); + // Make this the active game again so a refresh keeps resuming it. + persistActiveGameId(id); + setActiveGameId(id); + setGame(game); + setAdvisors([]); + setScreen({ name: 'game' }); + } catch (e) { + // Deleted / not found: clear any stale reference and stay on home. + clearActiveGameId(); + setActiveGameId(null); + setScreen({ name: 'home' }); + toastError(e); + } finally { + setLoading(false); + } + }, []); + const goHome = useCallback(() => { setScreen({ name: 'home' }); setGame(null); @@ -244,7 +343,14 @@ export default function App() { return ( <> - + ); } @@ -336,7 +442,7 @@ function GameScreen({ collapsible={false} title={
-
diff --git a/src/components/PresetPicker.tsx b/src/components/PresetPicker.tsx index de50980..c69fdb5 100644 --- a/src/components/PresetPicker.tsx +++ b/src/components/PresetPicker.tsx @@ -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 (
@@ -20,6 +41,30 @@ export default function PresetPicker({ presets, loading, onPlay }: Props) { world turn by turn — the LLM is the world engine.

+ + {/* Resume last game — prominent, shown whenever a game is remembered. */} + {activeGameId && onResume && ( +
+ +
+ )} +
{presets.map((p) => ( ))}
+ + {/* Saved games: get back into any prior game. */} + {savedGames.length > 0 && ( +
+

Saved games

+
+ {savedGames.map((g) => ( + + +
+ {g.presetTitle} + + {g.date} + +
+ Turn {g.turn} +
+ + + +
+ ))} +
+
+ )}
); } diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index c209f51..202fa11 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -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: '© OpenFreeMap', + }, + }, + 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; + } }; }, []); diff --git a/src/lib/api.ts b/src/lib/api.ts index 6061b0d..07cc01a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,7 +2,7 @@ // proxied by Vite in dev and served by Express in prod), so no origin is ever // hardcoded — this works unchanged behind the Traefik proxy. import type { GameState, Preset, PresetMeta } from '@shared/types'; -import type { AdvisorResponse, CreateGameResponse, TurnResponse } from '@shared/protocol'; +import type { AdvisorResponse, CreateGameResponse, GameSummary, TurnResponse } from '@shared/protocol'; async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { @@ -25,6 +25,9 @@ export const api = { body: JSON.stringify({ presetId, playerNationId }), }), getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`), + // Every saved game as a lightweight summary, newest-updated first (for the + // home screen's "Saved games" section / resume affordances). + listGames: () => req<{ games: GameSummary[] }>('/api/games'), turn: (id: string, action?: string, chat?: { to: string; message: string }) => req(`/api/games/${id}/turn`, { method: 'POST',