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:
+61
-1
@@ -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
|
* 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');
|
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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
res.json({ game: state });
|
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) => {
|
app.get('/api/games/:id', (req, res) => {
|
||||||
const game = db.load(req.params.id);
|
const game = db.load(req.params.id);
|
||||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
|||||||
+110
-4
@@ -26,7 +26,7 @@ import EventFeed from './components/EventFeed';
|
|||||||
import ActionPanel from './components/ActionPanel';
|
import ActionPanel from './components/ActionPanel';
|
||||||
import { api } from './lib/api';
|
import { api } from './lib/api';
|
||||||
import { useSse } from './lib/sse';
|
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';
|
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||||
|
|
||||||
/** Which top-level screen the app is showing. */
|
/** 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() {
|
export default function App() {
|
||||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||||
@@ -86,8 +119,13 @@ export default function App() {
|
|||||||
const [regions, setRegions] = useState<RegionMap>({});
|
const [regions, setRegions] = useState<RegionMap>({});
|
||||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (screen.name !== 'home') return;
|
if (screen.name !== 'home') return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -95,11 +133,44 @@ export default function App() {
|
|||||||
.listPresets()
|
.listPresets()
|
||||||
.then((r) => alive && setPresets(r.presets))
|
.then((r) => alive && setPresets(r.presets))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
api
|
||||||
|
.listGames()
|
||||||
|
.then((r) => alive && setSavedGames(r.games))
|
||||||
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [screen.name]);
|
}, [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).
|
// Keep the choropleth in sync with whatever state is shown (territory changes).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
@@ -118,6 +189,10 @@ export default function App() {
|
|||||||
|
|
||||||
const play = useCallback(async (presetId: string) => {
|
const play = useCallback(async (presetId: string) => {
|
||||||
setLoading(true);
|
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 {
|
try {
|
||||||
const { preset } = await api.getPreset(presetId);
|
const { preset } = await api.getPreset(presetId);
|
||||||
setScreen({ name: 'pick', preset });
|
setScreen({ name: 'pick', preset });
|
||||||
@@ -135,6 +210,9 @@ export default function App() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { game } = await api.createGame(screen.preset.id, nationId);
|
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);
|
setGame(game);
|
||||||
setAdvisors([]);
|
setAdvisors([]);
|
||||||
setScreen({ name: 'game' });
|
setScreen({ name: 'game' });
|
||||||
@@ -205,6 +283,27 @@ export default function App() {
|
|||||||
[game],
|
[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(() => {
|
const goHome = useCallback(() => {
|
||||||
setScreen({ name: 'home' });
|
setScreen({ name: 'home' });
|
||||||
setGame(null);
|
setGame(null);
|
||||||
@@ -244,7 +343,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster richColors position="top-right" />
|
<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}
|
collapsible={false}
|
||||||
title={
|
title={
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<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
|
← Scenarios
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
|
import { Play } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
import type { PresetMeta } from '@shared/types';
|
import type { PresetMeta } from '@shared/types';
|
||||||
|
import type { GameSummary } from '@shared/protocol';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
presets: PresetMeta[];
|
presets: PresetMeta[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onPlay: (presetId: string) => void;
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||||
<header className="mb-8">
|
<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.
|
world turn by turn — the LLM is the world engine.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<Card
|
<Card
|
||||||
@@ -44,6 +89,42 @@ export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-24
@@ -1,6 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import * as maplibregl from 'maplibre-gl';
|
import * as maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
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 { RegionMap, RegionOwner } from '@shared/protocol';
|
||||||
import type { Nation } from '@shared/types';
|
import type { Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -18,10 +25,49 @@ interface FeatureCollection {
|
|||||||
}
|
}
|
||||||
type GeoSource = maplibregl.GeoJSONSource;
|
type GeoSource = maplibregl.GeoJSONSource;
|
||||||
|
|
||||||
// Remote OpenFreeMap "dark" basemap — a near-black (rgb 12,12,12) canvas so the
|
// Inline basemap style — a visible dark-navy background plus a subtle
|
||||||
// choropleth nation colors pop. (Verified HTTP 200; the suggested dark-matter /
|
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
|
||||||
// dark-outdoors / liberty-dark variants do not exist on that host.)
|
// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn
|
||||||
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
|
// 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 COUNTRIES_SOURCE = 'countries';
|
||||||
const HOVER_SOURCE = 'hover';
|
const HOVER_SOURCE = 'hover';
|
||||||
@@ -151,8 +197,9 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
||||||
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
* 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
|
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
||||||
* borders, a brighter/thicker border for the player, hover + click popups with
|
* 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
|
* owner + stats, a transient flash on territory changes, and a
|
||||||
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
|
* 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).
|
* 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 cam = cameraForPreset(presetRef.current);
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: STYLE_URL,
|
style: INLINE_STYLE,
|
||||||
center: cam.center,
|
center: cam.center,
|
||||||
zoom: cam.zoom,
|
zoom: cam.zoom,
|
||||||
minZoom: 1.25,
|
minZoom: 1.25,
|
||||||
@@ -205,6 +252,10 @@ export default function WorldMap({
|
|||||||
attributionControl: { compact: true },
|
attributionControl: { compact: true },
|
||||||
});
|
});
|
||||||
mapRef.current = map;
|
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).
|
// Expose a tiny zoom/pan API to the floating control (null on unmount).
|
||||||
const api: MapApi = {
|
const api: MapApi = {
|
||||||
@@ -315,22 +366,21 @@ export default function WorldMap({
|
|||||||
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Idempotent: (re)adds the sources + layers after any setStyle and on first
|
// Idempotent: (re)creates the sources + layers after any style (re)load and
|
||||||
// load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
|
// (re)applies the ownership data. (Vite serves MapLibre's worker
|
||||||
const ensureLayers = async () => {
|
// automatically, so no setWorkerUrl is needed.)
|
||||||
let geo: FeatureCollection;
|
//
|
||||||
try {
|
// The three choropleth sources are created up front (empty until the base
|
||||||
geo = await loadGeo();
|
// geo arrives) so the layer structure always exists; `paintCountries` then
|
||||||
} catch (err) {
|
// stamps ownership + colors onto the countries source. This makes it immune
|
||||||
console.error('failed to load countries geojson', err);
|
// to load-order races: if `ensureLayers` runs before the geo fetch
|
||||||
return;
|
// 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
|
||||||
if (cancelled) return;
|
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
|
||||||
|
// regions effect re-paints whenever ownership changes.
|
||||||
|
const ensureSources = () => {
|
||||||
if (!map.getSource(COUNTRIES_SOURCE)) {
|
if (!map.getSource(COUNTRIES_SOURCE)) {
|
||||||
map.addSource(COUNTRIES_SOURCE, {
|
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||||
type: 'geojson',
|
|
||||||
data: applyRegions(geo, regionsRef.current, playerRef.current),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!map.getSource(HOVER_SOURCE)) {
|
if (!map.getSource(HOVER_SOURCE)) {
|
||||||
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
|
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||||
@@ -338,12 +388,38 @@ export default function WorldMap({
|
|||||||
if (!map.getSource(FLASH_SOURCE)) {
|
if (!map.getSource(FLASH_SOURCE)) {
|
||||||
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
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)) {
|
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({
|
map.addLayer({
|
||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: COUNTRIES_SOURCE,
|
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)) {
|
if (!map.getLayer(LINE_LAYER)) {
|
||||||
@@ -351,7 +427,7 @@ export default function WorldMap({
|
|||||||
id: LINE_LAYER,
|
id: LINE_LAYER,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: COUNTRIES_SOURCE,
|
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
|
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||||
@@ -409,6 +485,9 @@ export default function WorldMap({
|
|||||||
onMapReadyRef.current?.(null);
|
onMapReadyRef.current?.(null);
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
|
if (containerRef.current) {
|
||||||
|
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
||||||
// hardcoded — this works unchanged behind the Traefik proxy.
|
// hardcoded — this works unchanged behind the Traefik proxy.
|
||||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
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<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -25,6 +25,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ presetId, playerNationId }),
|
body: JSON.stringify({ presetId, playerNationId }),
|
||||||
}),
|
}),
|
||||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
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 }) =>
|
turn: (id: string, action?: string, chat?: { to: string; message: string }) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/turn`, {
|
req<TurnResponse>(`/api/games/${id}/turn`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user