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
+110 -4
View File
@@ -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<string, boolean>): 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<Screen>({ name: 'home' });
const [presets, setPresets] = useState<PresetMeta[]>([]);
@@ -86,8 +119,13 @@ export default function App() {
const [regions, setRegions] = useState<RegionMap>({});
const [advisors, setAdvisors] = useState<string[]>([]);
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<string | null>(() => readActiveGameId());
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
// 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 (
<>
<Toaster richColors position="top-right" />
<PresetPicker presets={presets} loading={loading} onPlay={play} />
<PresetPicker
presets={presets}
loading={loading}
onPlay={play}
savedGames={savedGames}
activeGameId={activeGameId}
onResume={resumeGame}
/>
</>
);
}
@@ -336,7 +442,7 @@ function GameScreen({
collapsible={false}
title={
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0">
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0" data-testid="back-to-scenarios">
← Scenarios
</Button>
<div className="flex min-w-0 items-center gap-2.5">