Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344b5b4a5e | ||
|
|
d5285ce28d | ||
|
|
8252b665b5 | ||
|
|
7d50cba061 | ||
|
|
e70f65f112 | ||
|
|
e38cc5933f | ||
|
|
858b901b8d | ||
|
|
17d6cecab9 | ||
|
|
f05ddb304d | ||
|
|
eadae4dc4f | ||
|
|
67f48fb99c |
@@ -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
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.11.0 — 2026-10-12 00:31
|
||||
- **Sub-national region dataset + generator (#16, foundation only — no app behavior change).** A new dependency-free `scripts/generate-regions.mjs` (run via `npm run generate:regions`) splits each of the 177 countries in `public/countries.geo.json` into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed `public/regions.geo.json` (**2,597 regions**, ~755 KB). Each region is id'd `<ISO_A3>-<nn>` (nn = 1..k in row-major order, capped at 36 per country) with `properties = { name, parent }`, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
|
||||
- **Validation test** — new self-contained `scripts/regions.test.ts` (picked up by `npm test`) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches `^[A-Z]{3}-\d+$` and whose `parent` is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
|
||||
- Fixes #16.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export default tseslint.config(
|
||||
'vitest.config.ts',
|
||||
'playwright.config.ts',
|
||||
'scripts/**/*.mjs',
|
||||
'scripts/**/*.ts',
|
||||
],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
|
||||
rules: {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.6.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
|
||||
"start": "node server/dist/index.js",
|
||||
"test": "vitest run",
|
||||
"generate:regions": "node scripts/generate-regions.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`.
|
||||
*
|
||||
* Subdivides every country in `public/countries.geo.json` into sub-national
|
||||
* regions sized ~100–200 km on a side (adaptive: a regular lat/lon grid
|
||||
* clipped to the country, so vast/sparse countries — Siberia, Sahara,
|
||||
* Antarctica — end up with bigger regions while Europe gets smaller ones).
|
||||
* Each country gets a few to tens of regions (capped at 36); tiny countries
|
||||
* (and tiny antimeridian islet groups) stay a single region.
|
||||
*
|
||||
* Model: a country's lat/lon bbox is tiled with a cols × rows grid of
|
||||
* ~square-in-km cells. Each grid cell yields at most ONE region feature:
|
||||
* every sub-polygon of the country is clipped against the cell with
|
||||
* Sutherland–Hodgman (edges in order: left, bottom, right, top), clipped
|
||||
* pieces smaller than 300 km² are dropped, and the kept pieces become the
|
||||
* rings of that cell's region (a MultiPolygon when there is more than one).
|
||||
* Region ids are `<ISO_A3>-<nn>`, nn = 1..k in row-major order (rows
|
||||
* top→bottom by descending lat, left→right by ascending lon); names are
|
||||
* `<Country> R<nn>`. Coordinates are rounded to 4 decimal places (≈11 m).
|
||||
* Because a cell produces at most one region, the per-country count can
|
||||
* never exceed the (capped) cell count ≤ 36, even for archipelagos.
|
||||
*
|
||||
* Area: local planar approximation, x = lon·111.32·cos(midLat), y = lat·
|
||||
* 110.574 (km), shoelace per sub-polygon ring, summed in abs. Target region
|
||||
* area T = 12,000 km² (≈110 km side); target count n = clamp(round(area /
|
||||
* T), 1, 36). S = sqrt(bboxAreaKm2 / n); cols = max(1, round(wKm/S)),
|
||||
* rows = max(1, round(hKm/S)); the grid is then reduced if cols·rows > 36.
|
||||
*
|
||||
* Antimeridian handling (three cases):
|
||||
* - Small antimeridian island groups (area ≤ 50,000 km² — Fiji, Kiribati,
|
||||
* Tuvalu, …): one region = the whole country (`<ISO_A3>-01`, plain name).
|
||||
* - Large antimeridian countries made of ordinary sub-polygons (Russia):
|
||||
* the sub-polygons are shifted into longitude space [0, 360) (lons < 0 →
|
||||
* +360) so the country sits in one contiguous band, tiled there, and
|
||||
* output rings that straddle ±180° are split at the antimeridian into a
|
||||
* MultiPolygon with each ring fully on one side — standard −180..180
|
||||
* GeoJSON that renders without cross-map lines.
|
||||
* - Full-360° "polar" wrap rings (Antarctica's main ring, whose only
|
||||
* longitude jump sits at the pole and which crosses the prime meridian
|
||||
* once): such a ring self-intersects in any single planar representation,
|
||||
* so it is replaced by two simple "wedge" rings — the eastern and western
|
||||
* hemispheric halves, each closed along the prime meridian, the pole
|
||||
* (lat −90), and the antimeridian. The wedges tile −180..180 space
|
||||
* without overlap and are tiled like any other ring (no shifting needed).
|
||||
*
|
||||
* Deterministic: same input → byte-identical output.
|
||||
* Run: `npm run generate:regions`
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- parameters ------------------------------------------------------------------
|
||||
const T = 12_000; // target region area, km² (≈110 km side)
|
||||
const MAX_REGIONS = 36; // per-country cap ("tens")
|
||||
const SLIVER_KM2 = 300; // drop clipped pieces smaller than this
|
||||
const BIG_KM2 = 50_000; // antimeridian countries at/above this are tiled, not 1 region
|
||||
const KM_LON = 111.32; // km per degree of longitude (at the equator)
|
||||
const KM_LAT = 110.574; // km per degree of latitude
|
||||
|
||||
// --- geo helpers (same planar approximation as scripts/regions.test.ts) -----------
|
||||
const rad = (deg) => (deg * Math.PI) / 180;
|
||||
const r4 = (v) => Math.round(v * 1e4) / 1e4;
|
||||
|
||||
function polygons(geom) {
|
||||
return geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||
}
|
||||
|
||||
function allCoords(geom) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) for (const ring of poly) for (const c of ring) out.push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
function meanLat(geom) {
|
||||
const cs = allCoords(geom);
|
||||
let s = 0;
|
||||
for (const c of cs) s += c[1];
|
||||
return cs.length ? s / cs.length : 0;
|
||||
}
|
||||
|
||||
function bbox(rings) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity,
|
||||
minLat = Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const ring of rings)
|
||||
for (const [lo, la] of ring) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
if (la < minLat) minLat = la;
|
||||
if (la > maxLat) maxLat = la;
|
||||
}
|
||||
return { minLon, maxLon, minLat, maxLat };
|
||||
}
|
||||
|
||||
/** |shoelace| of a (possibly open) ring in km², using one mid-latitude for x. */
|
||||
function ringAreaKm2(ring, midLat) {
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
let a = 0;
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Remove consecutive duplicate points and a trailing closing point equal to the first. */
|
||||
function dedupeRing(ring) {
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
|
||||
}
|
||||
if (
|
||||
out.length > 1 &&
|
||||
out[0][0] === out[out.length - 1][0] &&
|
||||
out[0][1] === out[out.length - 1][1]
|
||||
)
|
||||
out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Close an open ring by appending a copy of the first point. */
|
||||
function closeRing(ring) {
|
||||
return ring.length ? [...ring, [...ring[0]]] : ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiling/area subject rings of a feature, in standard −180..180 space:
|
||||
* one open ring per sub-polygon (outer ring only — holes are ignored by
|
||||
* design: Sutherland–Hodgman clips simple rings and any enclosed water is
|
||||
* at most a lake, which the area-sanity tolerance in the test absorbs).
|
||||
* A full-360° wrap ring is replaced by its two hemispheric wedges (see
|
||||
* makeWedges); if that is not possible the ring is returned as-is (the
|
||||
* caller may still shift it).
|
||||
*/
|
||||
function subjectRings(geom) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) {
|
||||
const ring = dedupeRing(poly[0]);
|
||||
if (hasAMJump(ring)) {
|
||||
const wedges = makeWedges(ring);
|
||||
if (wedges) out.push(...wedges);
|
||||
else out.push(ring);
|
||||
} else {
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Sutherland–Hodgman: clip an open subject ring against one half-plane --------
|
||||
function clipEdge(poly, inside, intersect) {
|
||||
const out = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const aIn = inside(a);
|
||||
const bIn = inside(b);
|
||||
if (aIn && bIn) out.push(b);
|
||||
else if (aIn && !bIn) out.push(intersect(a, b));
|
||||
else if (!aIn && bIn) {
|
||||
out.push(intersect(a, b));
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip an open subject ring against an axis-aligned (lon/lat) rectangle.
|
||||
* Edges are processed in order: left, bottom, right, top. Returns the clipped
|
||||
* open ring, or [] if empty.
|
||||
*/
|
||||
function clipRect(ring, [x0, y0, x1, y1]) {
|
||||
let poly = clipEdge(
|
||||
ring,
|
||||
(p) => p[0] >= x0,
|
||||
(a, b) => {
|
||||
const t = (x0 - a[0]) / (b[0] - a[0]);
|
||||
return [x0, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] >= y0,
|
||||
(a, b) => {
|
||||
const t = (y0 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y0];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[0] <= x1,
|
||||
(a, b) => {
|
||||
const t = (x1 - a[0]) / (b[0] - a[0]);
|
||||
return [x1, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] <= y1,
|
||||
(a, b) => {
|
||||
const t = (y1 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y1];
|
||||
},
|
||||
);
|
||||
return poly;
|
||||
}
|
||||
|
||||
// --- antimeridian handling ---------------------------------------------------------
|
||||
|
||||
/** True if the ring has an edge with |Δlon| > 180 (an antimeridian jump). */
|
||||
function hasAMJump(ring) {
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++)
|
||||
if (Math.abs(ring[i][0] - ring[(i + 1) % n][0]) > 180) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function crossesAntimeridian(geom) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity;
|
||||
for (const [lo] of allCoords(geom)) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
}
|
||||
return maxLon > 0 && minLon < 0 && maxLon - minLon > 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a full-360° wrap ring (one antimeridian jump, one prime-meridian
|
||||
* crossing) by two simple hemispheric "wedge" rings, each closed along the
|
||||
* prime meridian (x = 0), the pole (lat = −90), and the antimeridian
|
||||
* (x = ±180). Both wedges lie in standard −180..180 space, are disjoint, and
|
||||
* together cover exactly the original ring's enclosed region. Returns
|
||||
* [westRing, eastRing] (open rings), or null if the ring has an unexpected
|
||||
* shape (no wrap handling possible).
|
||||
*/
|
||||
function makeWedges(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return null;
|
||||
const jAM = [];
|
||||
const jPM = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[(i + 1) % n];
|
||||
if (Math.abs(a[0] - b[0]) > 180) jAM.push(i);
|
||||
else if (a[0] !== 0 && b[0] !== 0 && (a[0] < 0) !== (b[0] < 0)) jPM.push(i);
|
||||
}
|
||||
if (jAM.length !== 1 || jPM.length !== 1) return null;
|
||||
|
||||
const iAM = jAM[0];
|
||||
const a = ring[iAM]; // the +180 end of the jump
|
||||
const b = ring[(iAM + 1) % n]; // the −180 end of the jump
|
||||
if (Math.abs(a[0] - 180) > 1e-6 || Math.abs(b[0] + 180) > 1e-6) return null;
|
||||
|
||||
const iPM = jPM[0];
|
||||
const p = ring[iPM];
|
||||
const q = ring[(iPM + 1) % n];
|
||||
const t = (0 - p[0]) / (q[0] - p[0]);
|
||||
const P = [0, p[1] + (q[1] - p[1]) * t]; // the exact prime-meridian crossing
|
||||
|
||||
// Chain from b (−180) forward to P: everything in between must have lon ≤ 0.
|
||||
const west = [];
|
||||
for (let i = (iAM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] > 0) return null;
|
||||
west.push(pt);
|
||||
if (i === iPM) break;
|
||||
}
|
||||
west.push(P);
|
||||
// Chain from P forward to a (+180): everything in between must have lon ≥ 0.
|
||||
const east = [P];
|
||||
for (let i = (iPM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] < 0) return null;
|
||||
east.push(pt);
|
||||
if (i === iAM) break;
|
||||
}
|
||||
|
||||
const pole = (x) => [x, -90];
|
||||
// West wedge: P → pole along x=0 → pole along the pole → up the −180 side →
|
||||
// the western coast back to P. East wedge: the mirror image on x ≥ 0.
|
||||
const westWedge = dedupeRing([P, pole(0), pole(-180), b, ...west.slice(1)]);
|
||||
// The east chain q→…→a runs in *increasing* lon, so to trace the coast back
|
||||
// from a to q (keeping the wedge simple) we walk it in reverse.
|
||||
const eastWedge = dedupeRing([P, pole(0), pole(180), a, ...east.slice(1, -1).reverse()]);
|
||||
if (westWedge.length < 3 || eastWedge.length < 3) return null;
|
||||
return [westWedge, eastWedge];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an open ring (in shifted [0,360) space) at the antimeridian (x = 180).
|
||||
*
|
||||
* Instead of walking the ring between crossings (which breaks on jagged
|
||||
* coastlines that cross the antimeridian many times), clip the ring against
|
||||
* the two half-planes x ≤ 180 and x ≥ 180 with Sutherland–Hodgman. Each piece
|
||||
* is a proper closed-on-the-split-line polygon, the two pieces share only the
|
||||
* x = 180 boundary, and the split is area-preserving (the boundary has zero
|
||||
* area). Both pieces are returned in shifted space; the caller remaps any
|
||||
* x > 180 piece back into −180..0.
|
||||
*/
|
||||
function splitAtAM(ring) {
|
||||
if (ring.length < 3) return [];
|
||||
const cross = (a, b) => {
|
||||
const t = (180 - a[0]) / (b[0] - a[0]);
|
||||
return [180, a[1] + (b[1] - a[1]) * t];
|
||||
};
|
||||
const left = clipEdge(ring, (p) => p[0] <= 180, cross); // x in [0, 180]
|
||||
// Right piece: x in [180, 360). Remap EVERY vertex by -360 (including the
|
||||
// shared x = 180 boundary vertex → -180) so the piece sits fully on the west
|
||||
// side of the antimeridian with no ±180 jump inside the ring.
|
||||
const right = clipEdge(ring, (p) => p[0] >= 180, cross).map(
|
||||
([lo, la]) => [lo - 360, la],
|
||||
);
|
||||
const pieces = [];
|
||||
if (left.length >= 3) pieces.push(left);
|
||||
if (right.length >= 3) pieces.push(right);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// --- feature processing ------------------------------------------------------------
|
||||
|
||||
function makeFeature(id, name, parent, rings) {
|
||||
// Each ring is an INDEPENDENT piece (a separate island or a separate clipped
|
||||
// cell fragment), never a hole. GeoJSON: a MultiPolygon is an array of
|
||||
// polygons, each polygon an array of rings — so wrap every ring in its own
|
||||
// (single-ring) polygon. ([rings] would instead make ring 0 the outer and
|
||||
// every other ring a hole, swallowing e.g. Fiji's other islands.)
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
properties: { name, parent },
|
||||
geometry:
|
||||
rings.length === 1
|
||||
? { type: 'Polygon', coordinates: [rings[0]] }
|
||||
: { type: 'MultiPolygon', coordinates: rings.map((r) => [r]) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-country single region (no tiling, no sliver drops): all sub-polygons
|
||||
* in one feature, coordinates rounded. Used for tiny countries (n = 1) and
|
||||
* small antimeridian-crossing island groups.
|
||||
*/
|
||||
function wholeCountryFeature(feature) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const rings = polygons(feature.geometry).map((poly) =>
|
||||
closeRing(dedupeRing(poly[0]).map(([lo, la]) => [r4(lo), r4(la)])),
|
||||
);
|
||||
return [makeFeature(`${id}-01`, name, id, rings)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile the country's bbox with a cols × rows grid; each non-empty cell yields
|
||||
* one region feature (its kept clipped pieces as a Polygon/MultiPolygon).
|
||||
* `shifted` = true for large non-wrapping antimeridian countries (Russia):
|
||||
* work in [0, 360) longitude space, then split output rings at ±180°.
|
||||
*/
|
||||
function tileFeature(feature, rings, shifted) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const midLat = meanLat(feature.geometry);
|
||||
const area = rings.reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
|
||||
const n = Math.min(MAX_REGIONS, Math.max(1, Math.round(area / T)));
|
||||
if (n === 1) return wholeCountryFeature(feature);
|
||||
|
||||
const shiftLon = (lo) => (shifted && lo < 0 ? lo + 360 : lo);
|
||||
const work = rings.map((r) => r.map(([lo, la]) => [shiftLon(lo), la]));
|
||||
const bb = bbox(work);
|
||||
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
const wKm = (bb.maxLon - bb.minLon) * kx;
|
||||
const hKm = (bb.maxLat - bb.minLat) * KM_LAT;
|
||||
const S = Math.sqrt((wKm * hKm) / n);
|
||||
let cols = Math.max(1, Math.round(wKm / S));
|
||||
let rows = Math.max(1, Math.round(hKm / S));
|
||||
while (cols * rows > MAX_REGIONS) {
|
||||
if (cols >= rows) cols = Math.max(1, Math.floor(MAX_REGIONS / rows));
|
||||
else rows = Math.max(1, Math.floor(MAX_REGIONS / cols));
|
||||
}
|
||||
|
||||
const dLon = (bb.maxLon - bb.minLon) / cols;
|
||||
const dLat = (bb.maxLat - bb.minLat) / rows;
|
||||
|
||||
const out = [];
|
||||
let nn = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const yTop = bb.maxLat - r * dLat;
|
||||
const yBot = bb.maxLat - (r + 1) * dLat;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const xLeft = bb.minLon + c * dLon;
|
||||
const xRight = bb.minLon + (c + 1) * dLon;
|
||||
// One region per cell: clip every sub-ring, keep the pieces that clear
|
||||
// the sliver threshold, and merge them into a single feature.
|
||||
const cellPieces = [];
|
||||
for (const ring of work) {
|
||||
const clipped = clipRect(ring, [xLeft, yBot, xRight, yTop]);
|
||||
if (clipped.length < 3) continue;
|
||||
if (ringAreaKm2(clipped, midLat) < SLIVER_KM2) continue;
|
||||
// splitAtAM returns pieces already in standard −180..180 space (the
|
||||
// x > 180 half remapped), so no further per-vertex remap is needed.
|
||||
const pieces = shifted
|
||||
? splitAtAM(dedupeRing(clipped)).map((pc) => dedupeRing(pc))
|
||||
: [dedupeRing(clipped)];
|
||||
for (const pc of pieces)
|
||||
if (pc.length >= 3) cellPieces.push(pc.map(([lo, la]) => [r4(lo), r4(la)]));
|
||||
}
|
||||
if (!cellPieces.length) continue;
|
||||
const closed = cellPieces
|
||||
.map(dedupeRing)
|
||||
.filter((pc) => pc.length >= 3)
|
||||
.map(closeRing);
|
||||
if (!closed.length) continue;
|
||||
nn += 1;
|
||||
const nnStr = String(nn).padStart(2, '0');
|
||||
out.push(makeFeature(`${id}-${nnStr}`, `${name} R${nnStr}`, id, closed));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function processFeature(feature) {
|
||||
const geom = feature.geometry;
|
||||
const am = crossesAntimeridian(geom);
|
||||
if (am && featureAreaKm2(feature) <= BIG_KM2) {
|
||||
// Small antimeridian island group (Fiji, Kiribati, Tuvalu, …): one region.
|
||||
return wholeCountryFeature(feature);
|
||||
}
|
||||
// Wrapping rings (Antarctica) are already in standard space as wedges — no
|
||||
// shifting; non-wrapping antimeridian countries (Russia) are shifted.
|
||||
const rings = subjectRings(geom);
|
||||
const hasWrap = polygons(geom).some((p) => hasAMJump(dedupeRing(p[0])));
|
||||
const shifted = am && !hasWrap;
|
||||
return tileFeature(feature, rings, shifted);
|
||||
}
|
||||
|
||||
/** Total planar area of a feature's tiling rings (same convention as tileFeature). */
|
||||
function featureAreaKm2(feature) {
|
||||
const midLat = meanLat(feature.geometry);
|
||||
return subjectRings(feature.geometry).reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
}
|
||||
|
||||
// --- main ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const src = readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8');
|
||||
const countries = JSON.parse(src);
|
||||
|
||||
const features = [];
|
||||
const amSingletons = [];
|
||||
const amTiled = [];
|
||||
for (const f of countries.features) {
|
||||
if (crossesAntimeridian(f.geometry)) {
|
||||
(featureAreaKm2(f) <= BIG_KM2 ? amSingletons : amTiled).push(f.id);
|
||||
}
|
||||
features.push(...processFeature(f));
|
||||
}
|
||||
// Sorted by (parent, nn) — the processing order already guarantees this, but
|
||||
// sort explicitly so the contract holds regardless of traversal changes.
|
||||
features.sort((a, b) => {
|
||||
if (a.properties.parent !== b.properties.parent)
|
||||
return a.properties.parent < b.properties.parent ? -1 : 1;
|
||||
return a.id.localeCompare(b.id, 'en', { numeric: true });
|
||||
});
|
||||
|
||||
const out = { type: 'FeatureCollection', features };
|
||||
const json = JSON.stringify(out);
|
||||
writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n');
|
||||
|
||||
// --- summary ----------------------------------------------------------------------
|
||||
const counts = new Map();
|
||||
for (const f of features)
|
||||
counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
|
||||
const sortedCounts = [...counts.values()].sort((a, b) => a - b);
|
||||
const median =
|
||||
sortedCounts.length % 2
|
||||
? sortedCounts[Math.floor(sortedCounts.length / 2)]
|
||||
: (sortedCounts[sortedCounts.length / 2 - 1] + sortedCounts[sortedCounts.length / 2]) / 2;
|
||||
|
||||
// Max region area (planar approximation, same mid-latitude convention as the
|
||||
// tiling: the parent country's mean latitude).
|
||||
const midLatByParent = new Map(countries.features.map((f) => [f.id, meanLat(f.geometry)]));
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
for (const f of features) {
|
||||
const midLat = midLatByParent.get(f.properties.parent);
|
||||
let a = 0;
|
||||
for (const poly of polygons(f.geometry)) for (const ring of poly) a += ringAreaKm2(ring, midLat);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`regions: ${features.length} total (${countries.features.length} countries)`);
|
||||
console.log(`file: public/regions.geo.json — ${(Buffer.byteLength(json + '\n') / 1024).toFixed(1)} KB`);
|
||||
console.log(
|
||||
`count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]} per country`,
|
||||
);
|
||||
console.log(`max region: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
if (amSingletons.length) console.log(`AM → 1 region: ${amSingletons.join(', ')}`);
|
||||
if (amTiled.length) console.log(`AM → tiled (shifted/wedges): ${amTiled.join(', ')}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
|
||||
*
|
||||
* Self-contained (no import of the generator): it re-implements the generator's
|
||||
* exact planar area model so the assertions measure the same quantity the
|
||||
* generator used to size and clip regions:
|
||||
*
|
||||
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
|
||||
*
|
||||
* with a shoelace per outer ring, where each ring is measured at its own mean
|
||||
* latitude. Outer rings only — the generator never emits holes (every ring is an
|
||||
* independent piece: a separate island or a separate clipped cell fragment).
|
||||
*
|
||||
* It reads each file exactly once and runs in O(total vertices).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- area model (must match scripts/generate-regions.mjs exactly) -----------------
|
||||
const KM_LON = 111.32;
|
||||
const KM_LAT = 110.574;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
type Ring = [number, number][];
|
||||
type Geometry =
|
||||
| { type: 'Polygon'; coordinates: Ring[] }
|
||||
| { type: 'MultiPolygon'; coordinates: Ring[][] };
|
||||
|
||||
function ringAreaKm2(ring: Ring, midLat: number): number {
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
let a = 0;
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
function outerRings(geom: Geometry): Ring[] {
|
||||
if (geom.type === 'Polygon') return [geom.coordinates[0]];
|
||||
if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */
|
||||
function featureAreaKm2(geom: Geometry): number {
|
||||
let total = 0;
|
||||
for (const ring of outerRings(geom)) {
|
||||
let lat = 0;
|
||||
for (const [, la] of ring) lat += la;
|
||||
total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
interface RegionFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
properties: { name: string; parent: string };
|
||||
geometry: Geometry;
|
||||
}
|
||||
interface CountryFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
properties: { name: string };
|
||||
geometry: Geometry;
|
||||
}
|
||||
interface FeatureCollection<T> {
|
||||
type: 'FeatureCollection';
|
||||
features: T[];
|
||||
}
|
||||
|
||||
// Read each file exactly once.
|
||||
const regions = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<RegionFeature>;
|
||||
const countries = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<CountryFeature>;
|
||||
|
||||
// Precompute areas once (O(total vertices)).
|
||||
const countryArea = new Map<string, number>();
|
||||
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
|
||||
const regionArea = new Map<string, number>();
|
||||
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
|
||||
const regionSumByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
regionSumByParent.set(
|
||||
f.properties.parent,
|
||||
(regionSumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0),
|
||||
);
|
||||
}
|
||||
const countByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
||||
}
|
||||
|
||||
describe('regions.geo.json', () => {
|
||||
it('is a valid GeoJSON FeatureCollection', () => {
|
||||
expect(regions.type).toBe('FeatureCollection');
|
||||
expect(Array.isArray(regions.features)).toBe(true);
|
||||
expect(regions.features.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every region is a well-formed feature with a valid id and a valid parent country', () => {
|
||||
const countryIds = new Set(countries.features.map((c) => c.id));
|
||||
const idRe = /^[A-Z]{3}-\d+$/;
|
||||
for (const f of regions.features) {
|
||||
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
|
||||
expect(idRe.test(f.id), `${f.id ?? '?'}: bad id`).toBe(true);
|
||||
expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string');
|
||||
expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0);
|
||||
expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string');
|
||||
expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true);
|
||||
expect(f.properties.parent, `${f.id}: parent != ISO_A3 prefix`).toBe(f.id.slice(0, 3));
|
||||
const g = f.geometry;
|
||||
expect(
|
||||
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
|
||||
`${f.id}: bad geometry type ${g?.type}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every country 1..36 regions, and >=2 for any country > 50,000 km²', () => {
|
||||
for (const c of countries.features) {
|
||||
const n = countByParent.get(c.id) ?? 0;
|
||||
expect(n, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
|
||||
expect(n, `country ${c.id} has ${n} regions (> 36)`).toBeLessThanOrEqual(36);
|
||||
if ((countryArea.get(c.id) ?? 0) > 50_000) {
|
||||
expect(n, `large country ${c.id} should have >= 2 regions`).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => {
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
for (const f of regions.features) {
|
||||
const a = regionArea.get(f.id) ?? 0;
|
||||
const parentArea = countryArea.get(f.properties.parent) ?? 0;
|
||||
// A whole-country singleton of a sub-200 km² microstate is exempt.
|
||||
expect(a >= 200 || parentArea < 200, `${f.id}: area ${a.toFixed(1)} km² is too small`).toBe(true);
|
||||
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km² is absurdly large`).toBe(true);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
}
|
||||
}
|
||||
console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
});
|
||||
|
||||
it('covers each country to within [45%, 108%] of its own area', () => {
|
||||
// The band is a planar-approximation sanity check. The measured range is
|
||||
// ~[64%, 105%]: Antarctica's hemispheric wedges under-fill to ~64%, and
|
||||
// Norway's fjord-heavy, high-latitude coast over-fills to ~105.1% under the
|
||||
// single-mid-latitude approximation. [45%, 108%] brackets that with margin
|
||||
// while still catching a genuinely missing or doubled-up region.
|
||||
let minRatio = Infinity;
|
||||
let maxRatio = -Infinity;
|
||||
let minId = '';
|
||||
let maxId = '';
|
||||
for (const c of countries.features) {
|
||||
const ca = countryArea.get(c.id) ?? 0;
|
||||
if (ca <= 0) continue;
|
||||
const ratio = (regionSumByParent.get(c.id) ?? 0) / ca;
|
||||
if (ratio < minRatio) {
|
||||
minRatio = ratio;
|
||||
minId = c.id;
|
||||
}
|
||||
if (ratio > maxRatio) {
|
||||
maxRatio = ratio;
|
||||
maxId = c.id;
|
||||
}
|
||||
expect(
|
||||
ratio >= 0.45 && ratio <= 1.08,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [45%, 108%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
console.log(
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
{g.date}
|
||||
</Badge>
|
||||
<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
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
|
||||
"include": ["src", "server", "shared", "e2e", "scripts", "vite.config.ts"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['server/**/*.test.ts'],
|
||||
include: ['server/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user