Compare commits

...
6 Commits
Author SHA1 Message Date
kamilkrzywda 0ebdb36daa Merge pull request 'Switch map to sub-national regions (v0.12.0)' (#26) from feat/sub-national-map into master
CI / build + unit + e2e (push) Successful in 1m20s
2026-10-12 03:13:22 +02:00
kamilkrzywda 06cdbf3d81 Switch map to sub-national regions (v0.12.0)
CI / build + unit + e2e (pull_request) Successful in 1m18s
Part of #16
2026-10-12 01:07:33 +00:00
kamilkrzywda 344b5b4a5e Merge pull request 'Add sub-national regional dataset + generator (v0.11.0)' (#25) from feat/sub-national-regions into master
CI / build + unit + e2e (push) Successful in 1m21s
2026-10-12 02:47:21 +02:00
kamilkrzywda d5285ce28d Add sub-national regional dataset + generator (v0.11.0)
CI / build + unit + e2e (pull_request) Successful in 1m21s
Fixes #16
2026-10-12 00:33:11 +00:00
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
13 changed files with 1014 additions and 149 deletions
+25 -16
View File
@@ -27,8 +27,10 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
- Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth
from `public/countries.geo.json` (`id` = ISO_A3)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
`properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country**
granularity, so a sub-region inherits its parent country's owner color (see #16).
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
**injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport
(`LLM_STUB=1`) is used by all tests/e2e.
@@ -48,7 +50,8 @@ history/
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
│ └── regions.geo.json # 2,597 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
@@ -140,17 +143,22 @@ 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.
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
**parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave
the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable
tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution.
**PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the
LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's
color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions
individually ownable).
- **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
@@ -239,5 +247,6 @@ history/
caught.
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
- The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied
into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).
- The `dist/` client is committed to the build output only; the `public/` geo files
(`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite
at build time, so the prod image serves them from `dist/` (no separate copy).
+18
View File
@@ -2,6 +2,24 @@
All notable changes to **History** are listed here, newest first.
## v0.12.0 — 2026-10-12 01:03
- **Map now renders the sub-national regional dataset (#16, PR 2).** The choropleth source switches from `public/countries.geo.json` (177 countries) to `public/regions.geo.json` (**2,597 sub-regions**): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed `countries`/`countries-fill`/`countries-line` → `regions`/`regions-fill`/`regions-line`, and the border line is thinned to `0.75` (from `1.25`) so the subdivision reads within each country.
- **Ownership stays at country granularity.** A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's `properties.parent` (the parent country's ISO_A3) — never the sub-region id (`FRA-03`) nor the repacked top-level `feature.id`. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click.
- **New shared helper `mapFeatureOwner(regions, feature)`** in `@shared/protocol` — the single source of truth for resolving a rendered map feature's owner via `properties.parent` → `RegionMap[parent]` (built on the existing `regionOwner` key lookup). The territory-change **flash** now expands a changed country to **all** of its sub-regions (via the new `featuresByParents`), so a whole-country transfer still pulses correctly at the finer resolution.
- **No ownership-model or LLM change** — `buildRegionMap`/`RegionMap` remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3.
- **Tests** — new `mapFeatureOwner` unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric `id`, `regionId` = sub-region code, `parent` = ISO_A3) and proving resolution via `parent` while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry.
- Part of #16.
## 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.
+1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.9.0",
"version": "0.12.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
+520
View File
@@ -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();
+191
View File
@@ -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%)`,
);
});
});
+42 -20
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map';
import { nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
const state: GameState = {
@@ -74,40 +74,62 @@ describe('regionOwner', () => {
});
/**
* #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.
* #6 regression guard (sub-national map). The click/hover handlers resolve the
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
* source's top-level `id` into a *numeric index* when it stores the source, so a
* feature the map hands back at interaction time has a number for `id`. The
* choropleth is now sub-national: a feature's id is a sub-region code
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
* client stamps into `properties.parent` (the RegionMap is keyed by that
* country code, never the sub-region id). This pins the exact data shape and
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
* `regionId`, which is not a RegionMap key) do 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`).
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
// The shape MapLibre returns for a sub-region feature after setData(): `id`
// is a number (repacked), and the stable identifiers live only in properties —
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
const repackedFeature = {
id: 0,
properties: { name: 'DE', regionId: 'DEU' },
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: '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({
it('resolves the owner from properties.parent (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).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();
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by the parent 'DEU'.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
// reported "No owner" even for owned regions.
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
});
it('returns undefined for a parent country nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
});
});
describe('nationColor', () => {
it('is deterministic per id and distinct across different ids', () => {
expect(nationColor('uk')).toBe(nationColor('uk'));
+33 -12
View File
@@ -57,20 +57,41 @@ 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.
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
* the LLM stay at country granularity — see #16), so `key` is a country code such
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
* empty/missing key or an unowned country.
*
* 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).
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
* which reads the feature's `properties.parent` — never the repacked top-level
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
* the sub-region id.
*/
export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined {
if (!isoA3) return undefined;
return regions[isoA3];
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
return regions[key];
}
/**
* Resolve the owning-nation record for a **rendered map feature** from the
* authoritative RegionMap. The choropleth renders sub-national regions, but the
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
* `regionOwner`. This is the single shared source of truth for that resolution,
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule
* lives in one tested place rather than in the React component.
*
* Returns `undefined` for a missing feature/parent or an unowned country.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { parent?: unknown } } | null | undefined,
): RegionOwner | undefined {
const parent = feature?.properties?.parent;
return regionOwner(regions, typeof parent === 'string' ? parent : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
+26 -2
View File
@@ -589,6 +589,17 @@ function GameScreen({
}: GameScreenProps) {
// Which nation's dossier to show. Defaults to the player; remounts per game.
const [viewingId, setViewingId] = useState(game.playerNationId);
// When the dossier target was set by a map click, remember which sub-region
// (its own name + parent country) was clicked, for a small label in the
// dossier. Cleared when the user picks a nation via the manual Inspect select.
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
const focusNation = useCallback(
(id: string, info?: { parent?: string; name?: string }) => {
setViewingId(id);
setSelectedRegion(info ?? null);
},
[],
);
const player = game.nations[game.playerNationId];
const viewed = game.nations[viewingId] ?? player;
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
@@ -641,7 +652,7 @@ function GameScreen({
playerNationId={game.playerNationId}
nations={game.nations}
presetId={game.presetId}
onFocusNation={setViewingId}
onFocusNation={focusNation}
/>
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
@@ -747,7 +758,14 @@ function GameScreen({
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inspect
</div>
<Select value={viewingId} onValueChange={setViewingId}>
<Select
value={viewingId}
onValueChange={(id) => {
// A manual pick supersedes a map-click selection: clear the label.
setViewingId(id);
setSelectedRegion(null);
}}
>
<SelectTrigger className="w-full" data-testid="inspect-select">
<SelectValue />
</SelectTrigger>
@@ -759,6 +777,12 @@ function GameScreen({
))}
</SelectContent>
</Select>
{selectedRegion && (
<div className="mt-1.5 truncate text-[11px] text-muted-foreground" data-testid="selected-region">
Selected: {selectedRegion.name ?? selectedRegion.parent ?? 'region'}
{selectedRegion.parent ? ` (${selectedRegion.parent})` : ''}
</div>
)}
</div>
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
</FloatingWindow>
+153 -96
View File
@@ -8,11 +8,14 @@ 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 { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
interface GeoFeature {
type: 'Feature';
id?: string | number;
@@ -27,7 +30,7 @@ type GeoSource = maplibregl.GeoJSONSource;
// Inline basemap style — a visible dark-navy background plus a subtle
// OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain
// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn
// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn
// on top and is the real star.
//
// Why inline instead of a remote style URL: the OFM "dark" style's vector tiles
@@ -61,7 +64,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,
},
@@ -69,11 +72,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
],
};
const COUNTRIES_SOURCE = 'countries';
const REGIONS_SOURCE = 'regions';
const HOVER_SOURCE = 'hover';
const FLASH_SOURCE = 'flash';
const FILL_LAYER = 'countries-fill';
const LINE_LAYER = 'countries-line';
const FILL_LAYER = 'regions-fill';
const LINE_LAYER = 'regions-line';
const PLAYER_LINE = 'player-line';
const FLASH_LINE = 'flash-line';
const HOVER_FILL = 'hover-fill';
@@ -97,38 +100,48 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
}
/**
* Stamp the current ownership + colors onto a clone of the base geo. Each region
* 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.
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
* owns whole countries, so every sub-region of a country shares that country's
* owning nation's color — the map looks like the old country choropleth, but
* subdivided into finer cells. Each sub-region therefore carries:
*
* 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`).
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
* survives into the features MapLibre hands back on click/hover
* (the top-level `id` is repacked into a numeric index, #6);
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
* - `stroke` – a per-border alpha (owned brighter than unowned);
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
* player-highlight border).
*
* Crucially, the owner lookup keys the RegionMap by `properties.parent` (the
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
* `FRA-03` entries, only `FRA`.
*/
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 ?? {};
// 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;
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
// survives into the rendered features (the top-level `id` gets repacked).
const subId = f.id == null ? '' : String(f.id);
f.properties.regionId = subId;
// Ownership lives at the parent country: resolve `properties.parent`
// (the ISO_A3) against the RegionMap. Never the sub-region id.
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
f.properties.parent = parent;
const r = parent ? regions[parent] : undefined;
if (r) {
f.properties.fill = r.color;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.10)';
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;
@@ -137,10 +150,18 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
return out;
}
/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */
function featuresByCodes(geo: FeatureCollection, codes: Iterable<string>): FeatureCollection {
const set = new Set(codes);
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
/**
* Build a FeatureCollection of just the sub-regions whose **parent** country is
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
* must light up **all** of its sub-regions, not a single feature.
*/
function featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
const set = new Set(parents);
const features = geo.features.filter((f) => {
const parent = f.properties?.parent;
return typeof parent === 'string' && set.has(parent);
});
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
}
@@ -152,14 +173,20 @@ function statRow(label: string, value?: number): string {
return `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">${Math.round(value)}</span></div>`;
}
/** Compact hover tooltip: country, its owner (color-chipped), and 1–2 key stats. */
function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
/**
* Compact hover tooltip: the sub-region's name, its parent country (ISO_A3), its
* owner (color-chipped), and 1–2 key stats. When the parent country has no
* owning nation, a minimal "No owner" card is shown instead.
*/
function hoverHtml(subRegionName: string, parentCode: 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>`;
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || parentCode || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
const sub = parentCode ? `<div class="hm-popup-sub">${esc(parentCode)}</div>` : '';
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
${sub}
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
<div class="hm-stats">${stats}</div>
</div>`;
@@ -174,27 +201,38 @@ interface Props {
presetId: string;
className?: string;
/**
* 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.
* Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. For a sub-region, the owner is the parent
* country's owning nation. The optional `info` carries the clicked sub-region's
* own id/name and its parent country's ISO_A3, for a small "which region" label
* in the dossier. Not called for unowned regions (the dossier keeps its
* current target). See the component doc for the full behavior.
*/
onFocusNation?: (id: string) => void;
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
}
/**
* World basemap (MapLibre GL) + a country choropleth colored by the owning
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
* 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.
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
* the finer `regions.geo.json` dataset: each country is subdivided into
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
* lines. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
* nation owns whole countries, so all of a country's sub-regions share that
* country's owning-nation color. 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 (uniform per country, the choropleth is the
* star), thin sub-region borders, a brighter/thicker amber border for the
* player's countries, a hover tooltip (sub-region name + parent country + owner
* + key stats), a **click that switches the dossier to the sub-region's parent
* country's owning nation** (no popup — see #7), a transient flash when a
* country's owner changes (lighting up all its sub-regions), 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.
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
* never from the sub-region id (not a RegionMap key). Unowned countries (no
* RegionMap entry for the parent) 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.
@@ -255,9 +293,9 @@ export default function WorldMap({
const loadGeo = (): Promise<FeatureCollection> =>
baseGeoRef.current
? Promise.resolve(baseGeoRef.current)
: fetch('/countries.geo.json')
: fetch('/regions.geo.json')
.then((r) => {
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`);
return r.json();
})
.then((g: FeatureCollection) => {
@@ -268,12 +306,14 @@ export default function WorldMap({
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
// Transient flash: pulse a bright outline over the changed regions, then fade
// to zero over a few seconds and self-clean. Idempotent per call.
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
// up all of that country's sub-regions.
function animateFlash(codes: string[]) {
const geo = baseGeoRef.current;
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
if (!geo || !src || cancelled) return;
src.setData(featuresByCodes(geo, codes));
src.setData(featuresByParents(geo, codes));
if (!map.getLayer(FLASH_LINE)) {
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
// hover highlight when that layer exists; otherwise just append.
@@ -306,33 +346,34 @@ 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).
// The sub-region a hovered/clicked feature belongs to is identified by its
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
// and its **owner** is resolved from the authoritative RegionMap via the
// feature's `properties.parent` (the parent country's ISO_A3) through
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
// index, #6) and never from the sub-region id (not a RegionMap key).
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const p = f.properties as { name?: string; regionId?: string };
const code = typeof p?.regionId === 'string' ? p.regionId : '';
if (code && code !== lastHoverId) {
lastHoverId = code;
const p = f.properties as { name?: string; regionId?: string; parent?: string };
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
const parent = typeof p?.parent === 'string' ? p.parent : '';
if (subId && subId !== lastHoverId) {
lastHoverId = subId;
const geo = baseGeoRef.current;
// 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);
// The base GeoJSON feature's top-level `id` IS the sub-region id, so
// match on it to recover the exact feature for the hover-highlight
// outline (the feature MapLibre hands us has a repacked numeric `id`).
const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId);
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
);
}
const region = regionOwner(regionsRef.current, code);
const region = mapFeatureOwner(regionsRef.current, f);
const nation = region ? nationsRef.current[region.ownerId] : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? code, region, nation))
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -345,17 +386,24 @@ export default function WorldMap({
};
// #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.
// owning nation. For a sub-region, the owner is the **parent country's**
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
// RegionMap (never the repacked top-level `f.id`, #6, and never the
// sub-region id). A sub-region whose parent country has no owner leaves the
// current dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
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);
const region = mapFeatureOwner(regionsRef.current, f);
if (!region?.ownerId) return;
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
// the dossier can show which region was selected.
const p = f.properties as { regionId?: string; parent?: string; name?: string };
const info = {
parent: typeof p?.parent === 'string' ? p.parent : undefined,
name: typeof p?.name === 'string' ? p.name : undefined,
};
onFocusNationRef.current?.(region.ownerId, info);
};
// Idempotent: (re)creates the sources + layers after any style (re)load and
@@ -363,16 +411,16 @@ export default function WorldMap({
// automatically, so no setWorkerUrl is needed.)
//
// The three choropleth sources are created up front (empty until the base
// geo arrives) so the layer structure always exists; `paintCountries` then
// stamps ownership + colors onto the countries source. This makes it immune
// geo arrives) so the layer structure always exists; `paintRegions` then
// stamps ownership + colors onto the regions source. This makes it immune
// to load-order races: if `ensureLayers` runs before the geo fetch
// resolves, the data is applied the moment the fetch lands; if the geo
// resolves first, `ensureLayers` uses it. No source is ever left stuck on
// `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the
// regions effect re-paints whenever ownership changes.
const ensureSources = () => {
if (!map.getSource(COUNTRIES_SOURCE)) {
map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() });
if (!map.getSource(REGIONS_SOURCE)) {
map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() });
}
if (!map.getSource(HOVER_SOURCE)) {
map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() });
@@ -382,10 +430,10 @@ export default function WorldMap({
}
};
// Stamp ownership + colors onto the countries source (no-op until geo loads).
const paintCountries = () => {
// Stamp ownership + colors onto the regions source (no-op until geo loads).
const paintRegions = () => {
const geo = baseGeoRef.current;
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
};
@@ -394,32 +442,39 @@ export default function WorldMap({
if (cancelled) return;
ensureSources();
if (baseGeoRef.current) {
paintCountries();
paintRegions();
} else {
try {
await loadGeo();
} catch (err) {
console.error('failed to load countries geojson', err);
console.error('failed to load regions geojson', err);
}
if (cancelled) return;
paintCountries();
paintRegions();
}
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
// Every sub-region carries its parent country's owner color, so a whole
// country reads as one uniform fill — same as the old country map, but
// subdivided.
map.addLayer({
id: FILL_LAYER,
type: 'fill',
source: COUNTRIES_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
source: REGIONS_SOURCE,
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
});
}
if (!map.getLayer(LINE_LAYER)) {
// Thin internal borders: every sub-region outline at a light weight so
// the subdivision reads within each country's uniform fill. The per-
// feature `stroke` alpha keeps owned borders a touch brighter than
// unowned ones (the v0.10 treatment, thinned for the finer grid).
map.addLayer({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
source: REGIONS_SOURCE,
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.75, 'line-opacity': 0.95 },
});
}
// NOTE: layers are added in bottom→top order (each appends on top). The
@@ -430,7 +485,7 @@ export default function WorldMap({
map.addLayer({
id: PLAYER_LINE,
type: 'line',
source: COUNTRIES_SOURCE,
source: REGIONS_SOURCE,
filter: ['==', ['get', 'isPlayer'], 1],
paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 },
});
@@ -482,8 +537,10 @@ export default function WorldMap({
};
}, []);
// Re-color the choropleth whenever ownership changes, and flash any regions
// whose owner changed since the last update (transient, self-cleaning).
// Re-color the choropleth whenever ownership changes, and flash any country
// (all of its sub-regions) whose owner changed since the last update (a
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
// `animateFlash` expands each changed country to every one of its sub-regions.
useEffect(() => {
const map = mapRef.current;
const geo = baseGeoRef.current;
@@ -494,7 +551,7 @@ export default function WorldMap({
if (Object.keys(cur).length > 0) primedRef.current = true;
if (map && geo) {
const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
}
+1 -1
View File
@@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
"include": ["src", "server", "shared", "e2e", "scripts", "vite.config.ts"]
}
+1 -1
View File
@@ -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,
},