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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+4
-1
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<TurnResponse>(`/api/games/${id}/turn`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user