Compare commits

..
Author SHA1 Message Date
kamilkrzywda 8252b665b5 Merge pull request 'Fix map coastal-water bleed-through and border lines (v0.10.0)' (#24) from feat/map-border-style into master
CI / build + unit + e2e (push) Successful in 1m15s
2026-10-11 23:32:46 +02:00
kamilkrzywda 7d50cba061 Fix map coastal-water bleed-through and border lines (v0.10.0)
CI / build + unit + e2e (pull_request) Successful in 2m13s
Fixes #2
2026-10-11 21:26:25 +00:00
kamilkrzywda e70f65f112 Merge pull request 'Reduce turn latency: cap recent events (10→5) + concise-reasoning constraint (v0.9.0)' (#23) from feat/turn-latency-hardening into master
CI / build + unit + e2e (push) Successful in 2m16s
2026-10-11 23:03:53 +02:00
kamilkrzywda e38cc5933f Reduce turn latency: cap recent events (10→5) + concise-reasoning constraint (v0.9.0)
CI / build + unit + e2e (pull_request) Successful in 2m9s
Fixes #22
2026-10-11 20:59:18 +00:00
kamilkrzywda 858b901b8d Merge pull request 'v0.8.0 — remove a saved game & redirect on missing/deleted game (#20)' (#21) from feat/delete-saved-games into master
CI / build + unit + e2e (push) Successful in 1m39s
2026-10-11 22:35:58 +02:00
kamilkrzywda 17d6cecab9 feat: remove a saved game & redirect on missing/deleted game (#20)
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
2026-10-11 20:32:55 +00:00
kamilkrzywda f05ddb304d Merge pull request 'v0.7.0 — map click → dossier target; fix "no owner"' (#19) from map-click-dossier into master
CI / build + unit + e2e (push) Successful in 1m16s
2026-10-11 21:30:38 +02:00
kamilkrzywda eadae4dc4f map: click a region to switch the dossier to its owner; fix "no owner" (v0.7.0)
CI / build + unit + e2e (pull_request) Successful in 1m58s
2026-10-11 19:22:33 +00:00
kamilkrzywda 67f48fb99c Merge pull request 'v0.6.0 — advisor "thinking" indicator' (#18) from advisor-thinking into master
CI / build + unit + e2e (push) Successful in 2m5s
2026-10-11 20:38:56 +02:00
16 changed files with 466 additions and 57 deletions
+12 -1
View File
@@ -72,7 +72,7 @@ history/
│ └── utils.ts # cn()
└── components/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first)
@@ -140,6 +140,17 @@ history/
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
server's `RegionMap` and any client coloring so the choropleth always agrees.
- **Map click → dossier target (owner resolved from the RegionMap).** Clicking a map region does
NOT open a popup — it switches the dossier to that region's owning nation
(`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set
the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap`
by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId`
(properties survive into the rendered features) and the shared `regionOwner()` helper looks up.
It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's
`id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a
number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no
`RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The
hover path (a non-closeable tooltip with owner + key stats) uses the same resolution.
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
+23
View File
@@ -2,6 +2,29 @@
All notable changes to **History** are listed here, newest first.
## v0.10.0 — 2026-10-11 21:25
- **Fix world map style (#2)** — made the choropleth fill fully opaque (`fill-opacity` 0.85 → 1.0) and darkened the Natural Earth terrain raster (`raster-opacity` 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
- **Strengthened country border lines** — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
- Fixes #2.
## v0.9.0 — 2026-10-11 20:57
- **Reduced turn latency (prompt-level):** capped `recentEvents` in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (`reasoning_effort: low`); the deterministic stub, the `TurnResult` contract, `max_tokens`, and `reasoning_effort` are all unchanged.
## 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
- 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.
- Fixed: a clicked owned region no longer reports "no owner" (#6). Root cause: MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3 string) into a numeric index when it stores the source, so the feature a click/hover handler receives has a *number* for `id` — the old `regions[f.id]` lookup resolved to nothing, and the popup read "No owner" even for owned regions (the choropleth fill still worked because it is stamped from the original `id` before `setData`). Fix: `applyRegions` now stamps the stable ISO_A3 code into `properties.regionId` (and `ownerId`), which survives into the rendered features, and both the click and hover handlers resolve the owning nation from that code via the authoritative RegionMap — never from the repacked `feature.id`.
- Unowned regions (no entry in the RegionMap) do not change the dossier when clicked — the current target is left in place (a deliberate, non-crashing choice rather than a special "unowned" dossier state).
- New shared helper `regionOwner(regions, isoA3)` in `@shared/protocol` (the pure, single source of truth for "ISO_A3 → owner record").
- **Unit tests** — new `regionOwner` tests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numeric `id`, ISO_A3 in `properties.regionId`) and asserts resolution via `properties.regionId` works while the old `feature.id` path fails (44 tests, up from 39).
## v0.6.0 — 2026-10-11 18:27
- Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.
+83
View File
@@ -560,3 +560,86 @@ test.describe('History — Advisor thinking indicator (#15)', () => {
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
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.6.0",
"version": "0.10.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+15
View File
@@ -72,4 +72,19 @@ describe('openDb', () => {
expect(db.load('missing')).toBeNull();
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();
});
});
+5
View File
@@ -8,6 +8,7 @@ export interface Db {
save(state: GameState): void;
load(id: string): GameState | null;
listIds(): string[];
delete(id: string): boolean;
close(): void;
}
@@ -29,6 +30,7 @@ export function openDb(filePath: string): Db {
);
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
const del = db.prepare('DELETE FROM games WHERE id = ?');
return {
save(state) {
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
@@ -45,6 +47,9 @@ export function openDb(filePath: string): Db {
listIds() {
return (all.all() as { id: string }[]).map((r) => r.id);
},
delete(id) {
return del.run(id).changes > 0;
},
close() {
db.close();
},
+40 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import type { TurnResult } from '../shared/protocol';
import { makeEvent, type TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import {
applyTurnResult,
buildSystemPrompt,
buildUserPrompt,
isMeaningful,
parseTurnResult,
@@ -279,6 +280,44 @@ describe('buildUserPrompt (batched round)', () => {
expect(parsed.roundInstruction).toMatch(/no action/i);
expect(parsed.roundInstruction).toMatch(/own momentum/i);
});
it('caps recentEvents at the last 5 (in order) when the state has more than 5 events', () => {
const state = baseState();
const events = Array.from({ length: 8 }, (_, i) =>
makeEvent(
{
nation: 'uk',
type: 'political',
title: `Event ${i + 1}`,
text: `Narrative ${i + 1}`,
outcome: 'neutral',
},
i + 1,
`1939-09-0${i + 1}`,
),
);
state.events = events;
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
state: { recentEvents: Array<{ title: string; turn: number; date: string }> };
};
expect(parsed.state.recentEvents).toHaveLength(5);
// the last 5 of the 8 input events, in order
expect(parsed.state.recentEvents[0].title).toBe(events[3].title); // 'Event 4'
expect(parsed.state.recentEvents[4].title).toBe(events[7].title); // 'Event 8'
expect(parsed.state.recentEvents.map((e) => e.title)).toEqual([
events[3].title,
events[4].title,
events[5].title,
events[6].title,
events[7].title,
]);
});
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
});
});
describe('queueRoundItem', () => {
+2 -1
View File
@@ -59,6 +59,7 @@ export function buildSystemPrompt(preset: Preset): string {
'HARD CONSTRAINTS',
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
'- Only change borders when war or a decisive political shift justifies it.',
'- If a field is not relevant this turn, set it to an empty array.',
@@ -95,7 +96,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
nations: Object.fromEntries(
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
),
recentEvents: state.events.slice(-10).map((e) => ({
recentEvents: state.events.slice(-5).map((e) => ({
turn: e.turn,
date: e.date,
nation: e.nation,
+11
View File
@@ -156,6 +156,17 @@ app.get('/api/games/:id/map', (req, res) => {
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
// turn advance — the round is only committed (in one turn) by "Jump forward".
app.post('/api/games/:id/action', (req, res) => {
+65 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map';
import { nationColor } from '../shared/protocol';
import { nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
const state: GameState = {
@@ -33,6 +33,9 @@ const state: GameState = {
version: 1,
};
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => {
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
@@ -44,6 +47,67 @@ describe('buildRegionMap', () => {
});
});
describe('regionOwner', () => {
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
expect(regionOwner(regions, 'GBR')).toEqual({
color: nationColor('uk'),
owner: 'UK',
ownerId: 'uk',
});
expect(regionOwner(regions, 'DEU')).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
});
});
it('returns undefined for an unowned region (no RegionMap entry)', () => {
expect(regionOwner(regions, 'FRA')).toBeUndefined();
expect(regionOwner(regions, 'USA')).toBeUndefined();
});
it('returns undefined for an empty or missing code', () => {
expect(regionOwner(regions, '')).toBeUndefined();
expect(regionOwner(regions, null)).toBeUndefined();
expect(regionOwner(regions, undefined)).toBeUndefined();
});
});
/**
* #6 regression guard. The click/hover handlers resolve the owning nation from a
* map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3
* string) into a *numeric index* when it stores the source, so a feature the map
* hands back at interaction time has a number for `id` and the ISO_A3 only lives
* in `properties.regionId`. This test pins the exact data shape and proves the
* fix: resolution via `properties.regionId` works, while the pre-fix path that
* read `feature.id` does not.
*/
describe('#6 — owner must be resolved from properties.regionId, not the repacked feature.id', () => {
// The shape MapLibre returns for a source feature after setData(): `id` is a
// number (here 0, as observed in the live map), and the ISO_A3 is preserved
// only in properties (the client stamps it as `regionId`).
const repackedFeature = {
id: 0,
properties: { name: 'DE', regionId: 'DEU' },
};
it('resolves the owner from properties.regionId (the fix)', () => {
const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : '';
expect(regionOwner(regions, code)).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
});
});
it('the old path (feature.id as a string key) does NOT resolve — the bug', () => {
// String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed
// and the click popup reported "No owner" even for owned regions.
expect(regions[String(repackedFeature.id)]).toBeUndefined();
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
});
describe('nationColor', () => {
it('is deterministic per id and distinct across different ids', () => {
expect(nationColor('uk')).toBe(nationColor('uk'));
+22
View File
@@ -56,6 +56,23 @@ export interface RegionOwner {
}
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record for a map region from the authoritative RegionMap by
* its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned
* region. Pure + shared: the client resolves a clicked or hovered map feature by
* first reading the feature's stable ISO_A3 code (stored in `properties.regionId`
* by the client when it repacks the source — see WorldMap) and then calling this.
*
* The code must NOT be taken from the feature's top-level `id`: MapLibre repacks
* a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores
* the source, so at click/hover time `feature.id` is a number, never the ISO_A3
* — resolving from it is exactly the "no owner" bug (#6).
*/
export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined {
if (!isoA3) return undefined;
return regions[isoA3];
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
export type SseMessage =
| { type: 'state'; state: GameState }
@@ -124,6 +141,11 @@ export interface GameSummary {
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
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
+30
View File
@@ -309,6 +309,12 @@ export default function App() {
useSse(game?.id ?? null, {
onState: 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) => {
@@ -465,6 +471,29 @@ export default function App() {
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.
latestRef.current = { game, goHome, loadGame };
@@ -509,6 +538,7 @@ export default function App() {
savedGames={savedGames}
activeGameId={activeGameId}
onResume={resumeGame}
onDelete={runDeleteGame}
/>
</>
);
+70 -4
View File
@@ -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 { Button } from './ui/button';
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 { GameSummary } from '@shared/protocol';
@@ -15,12 +24,14 @@ interface Props {
activeGameId?: string | null;
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
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
* "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({
presets,
@@ -29,8 +40,14 @@ export default function PresetPicker({
savedGames = [],
activeGameId,
onResume,
onDelete,
}: Props) {
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 (
<div className="mx-auto max-w-6xl px-6 py-10">
@@ -90,7 +107,7 @@ export default function PresetPicker({
))}
</div>
{/* Saved games: get back into any prior game. */}
{/* Saved games: get back into any prior game (or delete it). */}
{savedGames.length > 0 && (
<section className="mt-12" data-testid="saved-games">
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
@@ -100,13 +117,28 @@ export default function PresetPicker({
key={g.id}
className="flex flex-col"
data-testid="saved-game"
data-game-id={g.id}
>
<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">
<div className="flex shrink-0 items-center gap-1">
<Badge variant="secondary" className="text-xs">
{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>
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
</CardHeader>
@@ -125,6 +157,40 @@ export default function PresetPicker({
</div>
</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>
);
}
+64 -46
View File
@@ -8,7 +8,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// 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 { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
@@ -61,7 +61,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
type: 'raster',
source: 'terrain',
paint: {
'raster-opacity': 0.5,
'raster-opacity': 0.15,
'raster-brightness-max': 0.8,
'raster-fade-duration': 200,
},
@@ -101,21 +101,36 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* carries its owner's stable fill color, a subtle stroke, and a flag for the
* player's own regions (rendered as a brighter, thicker border by a dedicated
* layer). Unknown owners fall back to a faint neutral fill — never an error.
*
* Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
* stores it, so a later click/hover event exposes that number on
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
* interaction time (the root cause of #6). `properties`, however, survive into the
* rendered features, so the interaction handlers resolve the owning nation from
* `properties.regionId` via the RegionMap (see `regionOwner`).
*/
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
f.properties = f.properties ?? {};
const r = f.id ? regions[f.id as string] : undefined;
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
// survives into the features MapLibre hands back on click/hover.
const code = f.id == null ? '' : String(f.id);
f.properties.regionId = code;
const r = code ? regions[code] : undefined;
if (r) {
f.properties.fill = r.color;
f.properties.owner = r.owner;
f.properties.stroke = 'rgba(255,255,255,0.10)';
f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.30)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
f.properties.fill = 'rgba(148,163,184,0.05)';
f.properties.stroke = 'rgba(255,255,255,0.06)';
f.properties.stroke = 'rgba(255,255,255,0.14)';
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
}
}
@@ -150,35 +165,19 @@ function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation):
</div>`;
}
/** Click popup: the full owning-nation profile with all four stats. */
function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [
statRow('Mil', nation.stats.military),
statRow('Eco', nation.stats.economy),
statRow('Stab', nation.stats.stability),
statRow('Pop', nation.stats.population),
].join('');
const regions = nation.territory.length;
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
<div class="hm-stats">${stats}</div>
</div>`;
}
interface Props {
regions: RegionMap;
playerNationId: string;
/** All nations, for hover/click stats and color chips. */
/** All nations, for the hover tooltip's owner stats and color chips. */
nations: Record<string, Nation>;
/** The active scenario id, for a sensible initial camera. */
presetId: string;
className?: string;
/** Called when a region is clicked, so the dossier can focus its owner. */
/**
* Called with a region's owning-nation id when that region is clicked, so the
* dossier switches to that nation. Not called for unowned regions (the dossier
* keeps its current target). See the component doc for the full behavior.
*/
onFocusNation?: (id: string) => void;
}
@@ -187,10 +186,18 @@ interface Props {
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
* 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. Zoom and pan are driven directly by the user
* (MapLibre wheel/trackpad + drag) — no floating control and no external API.
* border for the player, a hover tooltip (owner + key stats), a **click that
* switches the dossier to the region's owning nation** (no popup — see #7), a
* transient flash on territory changes, and a per-scenario initial camera.
*
* Ownership for a hovered/clicked region is resolved from the authoritative
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
* Unowned regions (no RegionMap entry) simply do not change the dossier.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
*/
export default function WorldMap({
regions,
@@ -243,7 +250,6 @@ export default function WorldMap({
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
let lastHoverId: string | null = null;
const loadGeo = (): Promise<FeatureCollection> =>
@@ -300,24 +306,33 @@ export default function WorldMap({
}
flashApiRef.current = animateFlash;
// The region a hovered/clicked feature belongs to is identified by its stable
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
// source's `id` into a numeric index when it stores the source, so the value
// exposed on `e.features[i].id` at interaction time is a number, never the
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const id = f.id == null ? null : String(f.id);
if (id && id !== lastHoverId) {
lastHoverId = id;
const p = f.properties as { name?: string; regionId?: string };
const code = typeof p?.regionId === 'string' ? p.regionId : '';
if (code && code !== lastHoverId) {
lastHoverId = code;
const geo = baseGeoRef.current;
const feat = geo?.features.find((x) => x.id != null && String(x.id) === id);
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
// that to recover the feature for the hover-highlight outline (the
// feature MapLibre hands us has a repacked numeric `id` we can't use).
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
);
}
const p = f.properties as { name?: string };
const region = id ? regionsRef.current[id] : undefined;
const region = regionOwner(regionsRef.current, code);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation))
.setHTML(hoverHtml(p?.name ?? code, region, nation))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -329,14 +344,17 @@ export default function WorldMap({
map.getCanvas().style.cursor = '';
};
// #7: a click does NOT open a popup — it switches the dossier to the region's
// owning nation. The owner is resolved from the stable ISO_A3 in
// `properties.regionId` via the RegionMap (never the repacked top-level
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
// leaves the current dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const id = f.id == null ? null : String(f.id);
const p = f.properties as { name?: string };
const region = id ? regionsRef.current[id] : undefined;
const nation = region ? nationsRef.current[region.ownerId] : undefined;
clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map);
const p = f.properties as { regionId?: string };
const code = typeof p?.regionId === 'string' ? p.regionId : '';
const region = regionOwner(regionsRef.current, code);
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
};
@@ -393,7 +411,7 @@ export default function WorldMap({
id: FILL_LAYER,
type: 'fill',
source: COUNTRIES_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
});
}
if (!map.getLayer(LINE_LAYER)) {
@@ -401,7 +419,7 @@ export default function WorldMap({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, 'line-opacity': 0.95 },
});
}
// NOTE: layers are added in bottom→top order (each appends on top). The
+4
View File
@@ -7,6 +7,7 @@ import type {
AddDiplomacyResponse,
AdvisorResponse,
CreateGameResponse,
DeleteGameResponse,
GameSummary,
RemoveQueueItemResponse,
TurnResponse,
@@ -33,6 +34,9 @@ export const api = {
body: JSON.stringify({ presetId, playerNationId }),
}),
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
// home screen's "Saved games" section / resume affordances).
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
+17
View File
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
interface SseHandlers {
onState?: (state: GameState) => 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 */
}
};
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('turn', onTurn);
es.addEventListener('error', onError);
return () => es.close();
}, [id]);
}