feat: remove a saved game & redirect on missing/deleted game (#20)
CI / build + unit + e2e (pull_request) Successful in 1m14s
CI / build + unit + e2e (pull_request) Successful in 1m14s
- DELETE /api/games/:id route + Db.delete() (404 when absent) - PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete) - App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found' (a deleted/missing game now returns the live tab and any deep link home) - DeleteGameResponse in shared/protocol.ts; api.deleteGame() - unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete returns the active tab home and a fresh deep link lands on home) - version 0.7.0 -> 0.8.0 + changelog Fixes #20
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.8.0 — 2026-10-11 20:29
|
||||||
|
|
||||||
|
- **Remove a saved game (#20)** — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new `DELETE /api/games/:id` route removes the game's row from SQLite and returns `{ deleted: true }` (404 if the id is unknown).
|
||||||
|
- **Redirect on a missing/deleted game (#20)** — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears *while its tab is open* (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE `error` event on delete, and the client's `useSse` gains an `onError` handler that returns the tab to the home screen (matching the existing "game not found" connect behavior).
|
||||||
|
- `PresetPicker` gained an `onDelete` prop, a per-card `data-game-id` attribute, the delete button, and the confirm dialog; `api.deleteGame(id)`, `Db.delete(id)`, and the shared `DeleteGameResponse` type were added to support it.
|
||||||
|
- **Unit + e2e tests** — new `Db.delete` test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is `{deleted:true}`, and a follow-up `GET` is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home).
|
||||||
|
|
||||||
## v0.7.0 — 2026-10-11 19:17
|
## v0.7.0 — 2026-10-11 19:17
|
||||||
|
|
||||||
- Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
|
- Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
|
||||||
|
|||||||
@@ -560,3 +560,86 @@ test.describe('History — Advisor thinking indicator (#15)', () => {
|
|||||||
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a saved game (#20): a saved game can be deleted from the home screen
|
||||||
|
* (with a confirmation dialog), and loading a non-existent / deleted game id
|
||||||
|
* (directly or via a stale open tab) returns the user to the home screen.
|
||||||
|
*/
|
||||||
|
test.describe('History — remove a saved game & redirect (#20)', () => {
|
||||||
|
test('deleting a saved game from the home screen removes it (with confirmation)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
|
||||||
|
// Back to the home screen, where the just-created game appears in "Saved games".
|
||||||
|
await page.getByTestId('back-to-scenarios').click();
|
||||||
|
await expect(page.getByTestId('saved-games')).toBeVisible({ timeout: 15000 });
|
||||||
|
const card = page.locator(`[data-game-id="${id}"]`);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Opening the confirm dialog…
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
const dialog = page.getByTestId('delete-game-confirm-dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// …and cancelling leaves the game in place.
|
||||||
|
await page.getByTestId('delete-game-cancel').click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Confirming the delete removes it: the DELETE succeeds, the card disappears,
|
||||||
|
// and a subsequent fetch reports the game gone.
|
||||||
|
await card.getByTestId('delete-game').click();
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toBeVisible();
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() === 'DELETE' &&
|
||||||
|
new URL(r.url()).pathname === `/api/games/${id}`,
|
||||||
|
),
|
||||||
|
page.getByTestId('delete-game-confirm').click(),
|
||||||
|
]);
|
||||||
|
expect((await resp.json()) as { deleted: boolean }).toEqual({ deleted: true });
|
||||||
|
await expect(page.getByTestId('delete-game-confirm-dialog')).toHaveCount(0);
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
|
||||||
|
// The game is gone from the server too.
|
||||||
|
const status = await page.evaluate(async (gid) => {
|
||||||
|
const r = await fetch(`/api/games/${gid}`);
|
||||||
|
return r.status;
|
||||||
|
}, id);
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting an open game sends the active tab home; a deep link lands on home', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const id = await createUkGame(page);
|
||||||
|
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||||
|
const origin = await page.evaluate(() => location.origin);
|
||||||
|
|
||||||
|
// Delete the game straight through the API while its tab is open. The server
|
||||||
|
// broadcasts an SSE `error` and the client's useSse onError handler returns
|
||||||
|
// this live tab to the home screen.
|
||||||
|
const delStatus = await page.evaluate(
|
||||||
|
async (gid) => (await fetch(`/api/games/${gid}`, { method: 'DELETE' })).status,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
expect(delStatus).toBe(200);
|
||||||
|
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page, '#/');
|
||||||
|
|
||||||
|
// A brand-new page deep-linking to the now-deleted id also lands on home.
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.goto(`${origin}/#/game/${id}`);
|
||||||
|
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||||
|
await waitHash(page2, '#/');
|
||||||
|
await page2.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
@@ -72,4 +72,19 @@ describe('openDb', () => {
|
|||||||
expect(db.load('missing')).toBeNull();
|
expect(db.load('missing')).toBeNull();
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
db.save(makeState('a'));
|
||||||
|
db.save(makeState('b'));
|
||||||
|
|
||||||
|
expect(db.delete('nope')).toBe(false); // unknown id
|
||||||
|
expect(db.delete('a')).toBe(true); // removes a
|
||||||
|
expect(db.load('a')).toBeNull();
|
||||||
|
expect(db.load('b')?.id).toBe('b');
|
||||||
|
expect([...db.listIds()].sort()).toEqual(['b']);
|
||||||
|
|
||||||
|
expect(db.delete('a')).toBe(false); // already gone
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Db {
|
|||||||
save(state: GameState): void;
|
save(state: GameState): void;
|
||||||
load(id: string): GameState | null;
|
load(id: string): GameState | null;
|
||||||
listIds(): string[];
|
listIds(): string[];
|
||||||
|
delete(id: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export function openDb(filePath: string): Db {
|
|||||||
);
|
);
|
||||||
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
||||||
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
||||||
|
const del = db.prepare('DELETE FROM games WHERE id = ?');
|
||||||
return {
|
return {
|
||||||
save(state) {
|
save(state) {
|
||||||
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
||||||
@@ -45,6 +47,9 @@ export function openDb(filePath: string): Db {
|
|||||||
listIds() {
|
listIds() {
|
||||||
return (all.all() as { id: string }[]).map((r) => r.id);
|
return (all.all() as { id: string }[]).map((r) => r.id);
|
||||||
},
|
},
|
||||||
|
delete(id) {
|
||||||
|
return del.run(id).changes > 0;
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ app.get('/api/games/:id/map', (req, res) => {
|
|||||||
res.json({ regions: buildRegionMap(game) });
|
res.json({ regions: buildRegionMap(game) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
|
||||||
|
// success broadcast an SSE `error` to any other open tab watching it so it
|
||||||
|
// returns home (see the `event: error` handling in src/lib/sse.ts).
|
||||||
|
app.delete('/api/games/:id', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const removed = db.delete(id);
|
||||||
|
if (!removed) return res.status(404).json({ error: 'game not found' });
|
||||||
|
broadcast(id, 'error', { message: 'game deleted' });
|
||||||
|
res.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Queue a free-text player action onto the pending round. No LLM call and no
|
// Queue a free-text player action onto the pending round. No LLM call and no
|
||||||
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
||||||
app.post('/api/games/:id/action', (req, res) => {
|
app.post('/api/games/:id/action', (req, res) => {
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ export interface GameSummary {
|
|||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
|
||||||
|
export interface DeleteGameResponse {
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||||||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||||||
|
|||||||
+30
@@ -309,6 +309,12 @@ export default function App() {
|
|||||||
useSse(game?.id ?? null, {
|
useSse(game?.id ?? null, {
|
||||||
onState: applyState,
|
onState: applyState,
|
||||||
onTurn: applyState,
|
onTurn: applyState,
|
||||||
|
// The game disappeared while this tab had it open — either deleted from
|
||||||
|
// another tab (the server broadcasts an SSE `error`) or a stale deep link to
|
||||||
|
// a missing game (the SSE connect emits `error`). Return this tab home.
|
||||||
|
onError: (msg) => {
|
||||||
|
if (/deleted|not found/i.test(msg)) latestRef.current?.goHome();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const play = useCallback(async (presetId: string) => {
|
const play = useCallback(async (presetId: string) => {
|
||||||
@@ -465,6 +471,29 @@ export default function App() {
|
|||||||
setAdvisors([]);
|
setAdvisors([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Delete a saved game. Removes it from the "Saved games" list, clears it if it
|
||||||
|
// was the remembered active game, and heads home if it's the one currently
|
||||||
|
// shown. The server also broadcasts an SSE `error` to any *other* open tab of
|
||||||
|
// this game, which the `useSse` `onError` handler above turns into a go-home.
|
||||||
|
const runDeleteGame = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteGame(id);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedGames((prev) => prev.filter((g) => g.id !== id));
|
||||||
|
if (activeGameId === id) {
|
||||||
|
clearActiveGameId();
|
||||||
|
setActiveGameId(null);
|
||||||
|
}
|
||||||
|
if (game?.id === id) goHome();
|
||||||
|
toast.success('Game deleted');
|
||||||
|
},
|
||||||
|
[activeGameId, game, goHome],
|
||||||
|
);
|
||||||
|
|
||||||
// Expose the latest game + handlers to the mount-once hashchange listener.
|
// Expose the latest game + handlers to the mount-once hashchange listener.
|
||||||
latestRef.current = { game, goHome, loadGame };
|
latestRef.current = { game, goHome, loadGame };
|
||||||
|
|
||||||
@@ -509,6 +538,7 @@ export default function App() {
|
|||||||
savedGames={savedGames}
|
savedGames={savedGames}
|
||||||
activeGameId={activeGameId}
|
activeGameId={activeGameId}
|
||||||
onResume={resumeGame}
|
onResume={resumeGame}
|
||||||
|
onDelete={runDeleteGame}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Play } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Play, Trash2 } 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
import type { PresetMeta } from '@shared/types';
|
import type { PresetMeta } from '@shared/types';
|
||||||
import type { GameSummary } from '@shared/protocol';
|
import type { GameSummary } from '@shared/protocol';
|
||||||
|
|
||||||
@@ -15,12 +24,14 @@ interface Props {
|
|||||||
activeGameId?: string | null;
|
activeGameId?: string | null;
|
||||||
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
||||||
onResume?: (id: string) => void;
|
onResume?: (id: string) => void;
|
||||||
|
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home screen: a grid of scenario cards (each with a Play button), a prominent
|
* 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
|
* "Resume last game" affordance, and a "Saved games" section listing prior
|
||||||
* games so the user can get back into one.
|
* games so the user can get back into one — or delete it.
|
||||||
*/
|
*/
|
||||||
export default function PresetPicker({
|
export default function PresetPicker({
|
||||||
presets,
|
presets,
|
||||||
@@ -29,8 +40,14 @@ export default function PresetPicker({
|
|||||||
savedGames = [],
|
savedGames = [],
|
||||||
activeGameId,
|
activeGameId,
|
||||||
onResume,
|
onResume,
|
||||||
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||||
|
// The game awaiting a delete confirmation (null = dialog closed).
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const confirmDeleteTarget = confirmDeleteId
|
||||||
|
? savedGames.find((g) => g.id === confirmDeleteId)
|
||||||
|
: 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">
|
||||||
@@ -90,7 +107,7 @@ export default function PresetPicker({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saved games: get back into any prior game. */}
|
{/* Saved games: get back into any prior game (or delete it). */}
|
||||||
{savedGames.length > 0 && (
|
{savedGames.length > 0 && (
|
||||||
<section className="mt-12" data-testid="saved-games">
|
<section className="mt-12" data-testid="saved-games">
|
||||||
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
||||||
@@ -100,13 +117,28 @@ export default function PresetPicker({
|
|||||||
key={g.id}
|
key={g.id}
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
data-testid="saved-game"
|
data-testid="saved-game"
|
||||||
|
data-game-id={g.id}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{g.date}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
{g.date}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="delete-game"
|
||||||
|
aria-label={`Delete ${g.presetTitle}`}
|
||||||
|
title="Delete this game"
|
||||||
|
onClick={() => setConfirmDeleteId(g.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -125,6 +157,40 @@ export default function PresetPicker({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm before permanently deleting a saved game. */}
|
||||||
|
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
|
||||||
|
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this game?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{confirmDeleteTarget
|
||||||
|
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
|
||||||
|
: 'This game will be permanently removed.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="delete-game-cancel"
|
||||||
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="delete-game-confirm"
|
||||||
|
onClick={() => {
|
||||||
|
const id = confirmDeleteId;
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
if (id) onDelete?.(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
@@ -33,6 +34,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}`),
|
||||||
|
// Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }.
|
||||||
|
deleteGame: (id: string) =>
|
||||||
|
req<DeleteGameResponse>(`/api/games/${id}`, { method: 'DELETE' }),
|
||||||
// Every saved game as a lightweight summary, newest-updated first (for the
|
// Every saved game as a lightweight summary, newest-updated first (for the
|
||||||
// home screen's "Saved games" section / resume affordances).
|
// home screen's "Saved games" section / resume affordances).
|
||||||
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
|
|||||||
interface SseHandlers {
|
interface SseHandlers {
|
||||||
onState?: (state: GameState) => void;
|
onState?: (state: GameState) => void;
|
||||||
onTurn?: (state: GameState, result: TurnResult) => void;
|
onTurn?: (state: GameState, result: TurnResult) => void;
|
||||||
|
/**
|
||||||
|
* Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON
|
||||||
|
* body `{ message }`) — e.g. the game was deleted or no longer exists. Pure
|
||||||
|
* connection errors (no data) are ignored and EventSource auto-reconnects.
|
||||||
|
*/
|
||||||
|
onError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,8 +43,19 @@ export function useSse(id: string | null, handlers: SseHandlers): void {
|
|||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onError = (e: Event) => {
|
||||||
|
if (e instanceof MessageEvent && e.data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data) as { message?: string };
|
||||||
|
if (d.message) handlersRef.current.onError?.(d.message);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
es.addEventListener('state', onState);
|
es.addEventListener('state', onState);
|
||||||
es.addEventListener('turn', onTurn);
|
es.addEventListener('turn', onTurn);
|
||||||
|
es.addEventListener('error', onError);
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user