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:
+110
-4
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user