Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf6eb236a | ||
|
|
9c29779572 | ||
|
|
3cd8dcecfa | ||
|
|
acb4f0800f | ||
|
|
b1081eefde |
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.19.0 — 2026-10-13 22:02
|
||||
- **Sub-region-level territory (PR 3 of #16): ownership, the LLM, and the map all operate at individual sub-region granularity instead of whole-country.**
|
||||
- `Nation.territory` is now a list of **sub-region ids** (`FR-57`, `DE-BB`, `US-AK`, … — the feature ids of `regions.geo.json`) rather than ISO_A3 country codes. At game creation (`POST /api/games`) each preset nation's ISO_A3 list is expanded to all its sub-region ids by the new `server/regions.ts` (a turf-free module that loads `public/`/`dist/regions.geo.json` once at boot into a region-id index + a country→regions map). Games saved before v0.19 are transparently migrated to region-level territory on read (`db.load` → `migrateTerritoryToRegions` — idempotent, and a no-op when the geo is not loaded).
|
||||
- **The LLM contract follows the map:** a `TurnResult`'s `territoryChanges` now gain/lose sub-region ids, so a border can shift **one region at a time** — a gained region is moved out of whichever nation currently holds it, and a lost region with no matching gainer becomes unowned no-man's-land (the `applyTurnResult` splice logic was already format-agnostic and is unchanged). The user prompt carries a new top-level `regions` reference table — `{ id: "name (parent)" }` for every region id present in the world state — so the model can map an id it is considering to a real place; the system prompt's hard constraints now name sub-region ids (e.g. `FR-57`). **Prompt-size measurement (full `wwii-1939`: 7 nations, 411 regions): the complete user prompt is ≈ 24.7k chars / ≈ 6.2k tokens at start (≈ 26.4k / ≈ 6.6k with 8 recent events + a 2-item round) — far under the ~80k frontier limit, so the full `regions` table is always sent (no frontier-limited mode was needed).**
|
||||
- **The choropleth is now truly per-region:** a nation's color fills exactly the sub-regions it owns, so a single country can be visibly split between nations, and unowned sub-regions render grey. Click/hover ownership resolves via the feature's `properties.regionId` (now the RegionMap key) through the shared `mapFeatureOwner` in `shared/protocol`, and the territory-change flash lights up exactly the regions whose owner changed (`featuresByRegionIds`, replacing the country-expanding `featuresByParents`).
|
||||
- **Graceful degradation:** when the geo file is missing or unreadable the region maps stay empty and `hasRegionData()` is false, so expansion, migration, the prompt's `regions` table, and ownership resolution all fall back to the pre-v0.19 whole-country ISO_A3 behavior — the server still boots and the app still works (with a one-time console warning).
|
||||
- **The deterministic stub stays deterministic** and now also makes exactly one region-level move per non-empty round: the player annexes the lexicographically-first sub-region of the first other nation (by key order) — empty rounds make no move — so unit tests and e2e assert the exact region that changes hands.
|
||||
- **Tests:** new `server/regions.test.ts` (real-geo expansion/migration, the not-loaded fallback path, and the fixture seams); `server/map.test.ts` and `server/engine.test.ts` re-pinned to region-id fixtures, including the #6 regression guard (owner from `properties.regionId`, never the repacked numeric `feature.id`) and new `applyTurnResult` tests for prior-owner transfer, no-owner gain, and no-gainer lose.
|
||||
- Part of #16
|
||||
|
||||
## v0.18.0 — 2026-10-13 18:54
|
||||
- **Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA").** The GeoJSON `properties.parent` is a *static geographic-origin* field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's `Selected:` label (as a `(FRA)` suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on `properties.parent` (unchanged) — only the display of it was removed.
|
||||
|
||||
## v0.17.0 — 2026-10-13 15:13
|
||||
- **Filled the no-one's-land left between merged sub-regions (Part of #16).** The v0.14 coarsening's boolean dissolves left slivers of each country's landmass inside no region — measured at **≈ 3.90M km² globally** (the union of a country's regions covered only ~98% of its landmass). The new `scripts/fill-gaps.mjs` (`npm run fill:regions`) closes them: per country it computes the gap (`landmass − union(its regions)`, `@turf/difference`+`union`), drops slivers < **25 km²** (66,637 of them — sub-pixel on the map), and assigns each remaining piece to the **nearest region** (centroid distance) as an appended MultiPolygon part. **2,696,811 km²** of no-one's-land was filled into **1,575 regions** (+6,768 parts: 5,026 → 11,794), leaving **63,889 km²** residual (**98.4% reduction** — the rest is coastline digitization fuzz and 14 tiny border-band overlap artifacts, see below). The named "messy" regions absorb their surroundings: **SI-048 Kočevje (SVN) 16,692 → 19,816 km² (57 → 90 parts)**, **MK-36 Kavadarci (MKD) 21,297 → 25,190 km² (8 → 17)**, **AZ-AGC Ağcabədi (AZE) 18,657 → 32,065 km² (7 → 14)**; the biggest single fills are **AQ-X01~ (Antarctica) +195,351 km²**, **CA-NU (Nunavut) +143,447**, **AQ-X02~ +142,615**, **CL-MA (Magallanes) +81,806**, **US-AK (Alaska) +63,830**, **CA-NT (NWT) +46,333**, **GL-UO (Kommuneqarfiup) +41,971**, **RU-KAM (Kamchatka) +41,911**.
|
||||
- **Correct, not just greedy: clipping, hole policy, dedup.** Each gap piece is clipped sequentially against (a) all *other countries'* regions, (b) its own country's existing regions, and (c) pieces already appended (bbox-touching) — so a piece straddling a border is split, and no piece can double-cover another region's area (**1,149,841 km² of foreign overhang removed** this way; 0 clip failures). **FR-57 Moselle (FRA) gains nothing on purpose**: its apparent gap was a cross-border phantom consumed by the neighbor clip — correct behavior. Holes follow a keep-or-drop policy: a gap hole whose centroid matches a **pre-existing hole** (within 10 km) or lies inside another country's landmass is kept, otherwise it's a phantom and dropped — so the **KZ-KZY Aral hole survives** (5,858 km² planar, the file's only hole; 5,875 km² of water kept open). A global dedup set guarantees each cleaned piece is appended at most once.
|
||||
- **The dataset contract is untouched.** Still **1,796 regions** with identical `id` / `properties.name` / `properties.parent` and order — the fill only *appends* parts; all **177 countries** covered; the single KZ-KZY hole intact. **14 genuinely new overlap pairs** remain (13 same-country border bands + 1 cross CN-FJ×TW-KIN) — a known polychip artifact of filling right up to a shared boundary (the pre-fill file already carried 3,690 pre-existing pairs), and they are sub-pixel at map scale, so they're accepted. The file grows **2,289,528 → 3,399,806 bytes (2.29 → 3.40 MB)** — under the 5 MB guard, so no Douglas–Peucker pass is needed.
|
||||
- **Deterministic + idempotent.** Fixed 25 km² threshold, deterministic ordering (centroid → area → id), 4-dp rounding (same convention as the other scripts). A fresh run on the pristine v0.16 file reproduces the shipped byte-for-byte (sha256 `87f44198…`), and re-running on the filled file is a no-op (byte-identical output).
|
||||
- **Tests** — `scripts/regions.test.ts` (now 9 tests): the v0.16 fixed-point pin is updated to the v0.17 topology (1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole); a new test asserts the **only** hole in the file is KZ-KZY and sits in a realistic 5,000–7,000 km² band; a new **grid-sampled per-country coverage** test (25×25 grid per country, inline even-odd ray cast) asserts no significant no-one's-land remains — median 1.8% / p90 6.1% / max 26.4% (BRN, a coastline-denseness artifact, not a real gap), with thresholds at 5% / 15% / 40% and ≤3 countries above 20%. The planar sum band's upper bound widens 175% → **190%**: the fill only adds area *inside* each country, so it can't shrink the pre-existing admin-1 over-extension tail (Vanuatu 178.9%, Solomon Is. 133% — tiny island nations whose admin-1 polygons extend beyond their own country polygon), while the old under-fill tail is gone (minimum is now Brunei at 78.8%, was ~52%).
|
||||
- **Type-check gotcha (why the test stays turf-free):** the project keeps `@types/geojson` **out of the tsc program** (tsconfig `types: ["vite/client", "node"]`). maplibre-gl's d.ts references the `GeoJSON` UMD global *without importing it*, so under `skipLibCheck` the reference degrades to `any` and `WorldMap.tsx`'s deliberately-minimal `GeoFeature` (with `geometry: unknown`) passes. The moment anything (e.g. a test importing `@turf/turf`) pulls `@types/geojson` into the program, those references typecheck for real and `WorldMap.tsx` fails with 7 errors. The v0.14–0.16 scripts are `.mjs` (never typechecked) for exactly this reason; the new test uses an inline ray cast instead of a geometry library, and `@turf/*` (a new devDependency, 137 packages including the meta `@turf/turf`) is only ever *run* by the `.mjs` scripts.
|
||||
- Part of #16
|
||||
|
||||
## v0.16.0 — 2026-10-13 09:37
|
||||
- **Smoothed the jagged sub-national region boundaries with Douglas–Peucker vertex-thinning (Part of #16).** The v0.14 coarsening (boolean dissolve) and Natural Earth's 1:10m digitization left some boundaries overly jagged — the worst being regions the dissolve split into many small zig-zag fragments, e.g. **SI-048 Kocevje (Slovenia, 57 parts)** and **MK-36 Kavadartsi (N. Macedonia, 8 parts)**. The new `scripts/smooth-regions.mjs` (`npm run smooth:regions`) runs `@turf/simplify` (Ramer–Douglas–Peucker, tolerance ~**50 m**) over **every ring of every polygon**, dropping the redundant "in-between" vertices of a shallow zig-zag while keeping the overall shape.
|
||||
- **Vertex-thinning only — no topology change.** DP only removes points: it never moves a border, never merges/dissolves a part, and never creates or removes a ring. So the dataset contract is fully preserved — still **1,796 regions** with identical `id` / `properties.name` / `properties.parent`, all **177 countries** covered, the same **5,026 parts / 5,027 rings**, the single **KZ-KZY** hole, and all **83 islands**. The 18 degenerate 3-point "micro-dot" rings (Ragged Island, the Paracels, …) are kept verbatim (`@turf/simplify` requires ≥4 pts and would otherwise throw).
|
||||
|
||||
Generated
+2305
-2
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.16.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -13,6 +13,7 @@
|
||||
"coarsen:regions": "node scripts/coarsen-regions.mjs",
|
||||
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||
"fill:regions": "node scripts/fill-gaps.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
@@ -42,7 +43,14 @@
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@turf/area": "^7.4.0",
|
||||
"@turf/bbox": "^7.4.0",
|
||||
"@turf/boolean-point-in-polygon": "^7.4.0",
|
||||
"@turf/centroid": "^7.4.0",
|
||||
"@turf/difference": "^7.4.0",
|
||||
"@turf/distance": "^7.4.0",
|
||||
"@turf/simplify": "^7.4.0",
|
||||
"@turf/turf": "^7.4.0",
|
||||
"@turf/union": "^7.4.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^4.17.25",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fill "no-one's-land" gaps left in `public/regions.geo.json` by the v0.14 coarsening merge.
|
||||
*
|
||||
* For each country, the gap = (country landmass) − (union of the country's sub-regions):
|
||||
* area inside the country's authoritative landmass that no sub-region covers. Each gap
|
||||
* fragment is assigned to the **nearest** sub-region (centroid haversine distance) and
|
||||
* appended to it as extra polygon part(s). A fragment is only added if:
|
||||
*
|
||||
* • it is not a sub-tiny sliver (≥ GAP_MIN_KM2),
|
||||
* • it does not fall inside an existing region's *hole* (water) — this keeps the
|
||||
* Aral-basin hole in KZ-KZY open,
|
||||
* • it is not byte-identical to a part the file already contains (polyclip sometimes
|
||||
* re-emits a region's own outer ring with a phantom sliver hole; once the hole is
|
||||
* cleaned the piece equals the existing part, and appending it would only grow the
|
||||
* file on every re-run),
|
||||
* • it has been clipped against every region of *other* countries (and against pieces
|
||||
* already appended this run) that touch its bbox, so it never overlaps an existing
|
||||
* region — no new cross-border overlaps are introduced.
|
||||
*
|
||||
* Holes found in gap fragments are kept only when they are real: pre-existing region
|
||||
* holes (matched by centroid) or enclaves of another country (e.g. Lesotho inside ZAF,
|
||||
* so the ZAF fragment stays a ring and never covers the LSO region); every other hole
|
||||
* is a polyclip phantom and is dropped.
|
||||
*
|
||||
* Standard GeoJSON (`{type, coordinates}`) throughout — the same shape the file and
|
||||
* MapLibre already use, and what @turf v7 expects.
|
||||
*
|
||||
* Usage: node scripts/fill-gaps.mjs [inFile [outFile]]
|
||||
* inFile default: public/regions.geo.json
|
||||
* outFile default: inFile (edit in place)
|
||||
* Env: GAP_MIN_KM2 (default 25) — gap fragments below this area are ignored slivers.
|
||||
*
|
||||
* Deterministic: same input → same output. Re-running on an already-filled file is a
|
||||
* no-op (every re-discovered fragment is either a sub-threshold sliver, a phantom that
|
||||
* byte-matches an existing part, or already covered by the enlarged union), so the
|
||||
* result is idempotent.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
import difference from "@turf/difference";
|
||||
import union from "@turf/union";
|
||||
import centroid from "@turf/centroid";
|
||||
import bbox from "@turf/bbox";
|
||||
import area from "@turf/area";
|
||||
import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
|
||||
import distance from "@turf/distance";
|
||||
import { feature, featureCollection, multiPolygon, polygon, point } from "@turf/helpers";
|
||||
|
||||
const GAP_MIN_KM2 = Number(process.env.GAP_MIN_KM2 ?? 25);
|
||||
const RING_MIN_KM2 = 1e-4; // drop degenerate (numerical-noise) rings
|
||||
|
||||
// --------------------------------------------------------------------------- helpers
|
||||
|
||||
/**
|
||||
* Round coordinates in place to 6 decimals (file convention). Accepts either a single
|
||||
* ring (array of positions) or a polygon part (array of rings) and detects the depth.
|
||||
*/
|
||||
function roundCoords(coords) {
|
||||
const isRing = Array.isArray(coords[0]) && Array.isArray(coords[0][0]) ? false : true;
|
||||
const rings = isRing ? [coords] : coords;
|
||||
for (const ring of rings) for (const c of ring) {
|
||||
c[0] = Number(c[0].toFixed(6));
|
||||
c[1] = Number(c[1].toFixed(6));
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
/** Area (km²) of a closed ring, 0 when degenerate/NaN. */
|
||||
function ringKm2(ring) {
|
||||
if (!isValidRing(ring)) return 0;
|
||||
try { return area(polygon([ring])) * 1e-6; } catch { return 0; }
|
||||
}
|
||||
|
||||
/** A valid GeoJSON ring: ≥4 closed positions, each [finiteNumber, finiteNumber]. */
|
||||
function isValidRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||
for (const c of ring) {
|
||||
if (!Array.isArray(c) || c.length < 2) return false;
|
||||
if (typeof c[0] !== "number" || !Number.isFinite(c[0])) return false;
|
||||
if (typeof c[1] !== "number" || !Number.isFinite(c[1])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Module-level refs populated in main before use (see keepHole).
|
||||
let preHoleCentroids = []; // centroids of holes present in the input file (kept verbatim)
|
||||
let otherLandmasses = []; // { iso, feature } for every country landmass
|
||||
|
||||
/**
|
||||
* Is this hole a "real" one that must be preserved? Kept when it is a pre-existing
|
||||
* region hole (centroid match, e.g. the Aral) or lies inside another country's
|
||||
* landmass (enclave, e.g. Lesotho). Otherwise it is a polyclip phantom → dropped.
|
||||
*/
|
||||
function keepHole(hole, currentIso) {
|
||||
const hKm2 = ringKm2(hole);
|
||||
if (!(hKm2 >= RING_MIN_KM2)) return false; // degenerate / NaN
|
||||
const cm = centroid(polygon([hole])).geometry.coordinates;
|
||||
if (preHoleCentroids.some((p) => distance(cm, p) < 10)) return true;
|
||||
if (otherLandmasses.some((l) => l.iso !== currentIso && booleanPointInPolygon(point(cm), l.feature))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a GeoJSON polygon part (`[outerRing, ...holeRings]`): round coordinates, keep
|
||||
* only real holes (see keepHole). Returns the cleaned part, or null if the outer ring
|
||||
* is degenerate.
|
||||
*/
|
||||
function cleanPolygonPart(part, currentIso) {
|
||||
if (!Array.isArray(part) || part.length === 0) return null;
|
||||
const outer = part[0];
|
||||
if (!isValidRing(outer)) return null;
|
||||
const outerKm2 = ringKm2(outer);
|
||||
if (!(outerKm2 >= RING_MIN_KM2)) return null; // covers NaN too
|
||||
const holes = part.slice(1).filter((h) => isValidRing(h) && keepHole(h, currentIso));
|
||||
roundCoords([outer, ...holes]);
|
||||
return [outer, ...holes];
|
||||
}
|
||||
|
||||
/** Split a geometry (Polygon|MultiPolygon) into cleaned polygon parts. */
|
||||
function partsOf(geom, iso) {
|
||||
if (!geom) return [];
|
||||
const raw = geom.type === "MultiPolygon" ? geom.coordinates : [geom.coordinates];
|
||||
const out = [];
|
||||
for (const p of raw) { const c = cleanPolygonPart(p, iso); if (c) out.push(c); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rebuild a geometry (NOT a feature) from cleaned parts. */
|
||||
function toGeometry(parts) {
|
||||
// turf helpers return Features; take their .geometry ({type, coordinates}).
|
||||
return parts.length > 1 ? multiPolygon(parts).geometry : polygon(parts[0]).geometry;
|
||||
}
|
||||
|
||||
/** Bbox of a polygon part, as [west, south, east, north]. */
|
||||
function partBBox(part) { return bbox(polygon(part)); }
|
||||
|
||||
function bBoxesOverlap(a, b) {
|
||||
return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
|
||||
}
|
||||
|
||||
/** Sample points along a part's outer ring (start + every ~max(1, n/16)th vertex). */
|
||||
function samplePoints(part, maxN = 16) {
|
||||
const ring = part[0];
|
||||
const n = ring.length;
|
||||
if (n <= maxN) return ring;
|
||||
const step = Math.ceil(n / maxN);
|
||||
const out = [ring[0]];
|
||||
for (let i = step; i < n; i += step) out.push(ring[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Module-level list of pre-existing region holes (water guard), built in main.
|
||||
let holeRings = []; // { bbox, poly }
|
||||
|
||||
/** Does a gap piece overlap any pre-existing region hole (water)? */
|
||||
function overlapsAnyHole(piece, pieceCm) {
|
||||
const pBBox = partBBox(piece);
|
||||
const candidates = holeRings.filter((h) => bBoxesOverlap(pBBox, h.bbox));
|
||||
if (candidates.length === 0) return false;
|
||||
const pts = [pieceCm, ...samplePoints(piece)];
|
||||
return candidates.some((h) => pts.some((p) => booleanPointInPolygon(point(p), h.poly)));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- main
|
||||
|
||||
const inFile = process.argv[2] ?? "public/regions.geo.json";
|
||||
const outFile = process.argv[3] ?? inFile;
|
||||
|
||||
const geo = JSON.parse(fs.readFileSync(inFile, "utf8"));
|
||||
const countries = JSON.parse(fs.readFileSync(new URL("../public/countries.geo.json", import.meta.url), "utf8"));
|
||||
|
||||
otherLandmasses = countries.features.map((f) => ({ iso: f.id, feature: f }));
|
||||
|
||||
const features = geo.features;
|
||||
const regionsByParent = new Map();
|
||||
for (const f of features) {
|
||||
const p = f.properties.parent;
|
||||
if (!regionsByParent.has(p)) regionsByParent.set(p, []);
|
||||
regionsByParent.get(p).push(f);
|
||||
}
|
||||
|
||||
// Region inner-ring (hole) list for the water-hole guard; also the centroids of every
|
||||
// pre-existing hole (so the hole policy never destroys one, e.g. KZ-KZY's Aral hole).
|
||||
for (const f of features) {
|
||||
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
|
||||
for (const p of parts) for (const h of p.slice(1)) {
|
||||
if (Array.isArray(h) && h.length >= 4 && ringKm2(h) >= RING_MIN_KM2) {
|
||||
const poly = polygon([h]);
|
||||
holeRings.push({ bbox: bbox(poly), poly });
|
||||
preHoleCentroids.push(centroid(poly).geometry.coordinates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every part already in the input file (string key) — used to skip phantom pieces that
|
||||
// re-emiss an existing part byte-for-byte.
|
||||
const existingPartSet = new Set();
|
||||
for (const f of features) {
|
||||
const parts = f.geometry.type === "MultiPolygon" ? f.geometry.coordinates : [f.geometry.coordinates];
|
||||
for (const p of parts) existingPartSet.add(JSON.stringify(p));
|
||||
}
|
||||
|
||||
// All regions with bboxes, for cross-border clipping.
|
||||
const allRegions = features.map((f) => ({
|
||||
feature: f,
|
||||
parent: f.properties.parent,
|
||||
bbox: bbox(feature(f.geometry)),
|
||||
}));
|
||||
|
||||
console.log(`input: ${inFile}`);
|
||||
console.log(`regions: ${features.length} countries(with regions): ${regionsByParent.size} region holes: ${holeRings.length}`);
|
||||
|
||||
// Per-region running totals for the report.
|
||||
const added = new Map(); // feature -> { km2, parts }
|
||||
let totalSliverKm2 = 0, totalWaterKm2 = 0, totalFilledKm2 = 0, totalDupN = 0, totalClippedKm2 = 0, totalClipFailN = 0;
|
||||
|
||||
// Pieces already appended this run (any country) — later pieces clip against these so
|
||||
// appended pieces never overlap each other either.
|
||||
const appendedSoFar = []; // { parent, bbox, part }
|
||||
|
||||
for (const [iso, countryRegions] of regionsByParent) {
|
||||
const landmass = countries.features.find((c) => c.id === iso);
|
||||
if (!landmass) continue; // no authoritative landmass → nothing to fill against
|
||||
const feats = countryRegions.map((r) => feature(r.geometry));
|
||||
|
||||
let unionGeom, diffGeom;
|
||||
try {
|
||||
// A country with a single sub-region has no union to compute: it IS the union.
|
||||
unionGeom = countryRegions.length === 1
|
||||
? countryRegions[0].geometry
|
||||
: union(featureCollection(feats)).geometry;
|
||||
diffGeom = difference(featureCollection([landmass, { ...landmass, geometry: unionGeom }])).geometry;
|
||||
} catch (e) {
|
||||
console.log(`${iso}\tSKIPPED (${unionGeom ? "difference" : "union"} failed: ${e.message})`);
|
||||
continue;
|
||||
}
|
||||
if (!diffGeom) continue;
|
||||
|
||||
const rawPieces = partsOf(diffGeom, iso); // cleaned gap fragments inside the landmass
|
||||
|
||||
// Pass 1: filter (sliver / water / duplicate) and clip against foreign territory.
|
||||
const fillable = [];
|
||||
let sliverKm2 = 0, waterKm2 = 0, dupN = 0, clippedKm2 = 0, sliverN = 0, waterN = 0, clipFailN = 0;
|
||||
for (const piece of rawPieces) {
|
||||
const piecePoly = polygon(piece);
|
||||
const aKm2 = area(piecePoly) * 1e-6;
|
||||
if (aKm2 < GAP_MIN_KM2) { sliverKm2 += aKm2; sliverN++; continue; }
|
||||
|
||||
const pieceCm = centroid(piecePoly).geometry.coordinates;
|
||||
if (overlapsAnyHole(piece, pieceCm)) { waterKm2 += aKm2; waterN++; continue; }
|
||||
if (existingPartSet.has(JSON.stringify(piece))) { dupN++; continue; }
|
||||
|
||||
// Clip: remove any part of this piece that ANY existing region (own or foreign
|
||||
// country) or an already-appended piece claims. Clipped *sequentially* against
|
||||
// each touching target (more robust than one big union-difference): a target that
|
||||
// fails to clip is skipped and counted, not allowed to abort the whole piece.
|
||||
// Clipping against the country's OWN regions as well as foreign ones is what kills
|
||||
// the same-country overlaps: the gap is `landmass − union(own regions)`, and
|
||||
// polychip's difference can leave thin slivers of an own region inside that "gap";
|
||||
// re-differencing each fragment against the individual regions (simpler operands
|
||||
// than the big union) strips them out. Both operands are built as proper Features
|
||||
// carrying a real geometry — never `feature()` on an already-Feature object (that
|
||||
// double-wraps the geometry into the Feature itself and breaks polychip).
|
||||
const pBBox = partBBox(piece);
|
||||
const targets = [
|
||||
...allRegions.filter((r) => bBoxesOverlap(r.bbox, pBBox)).map((r) => ({ feature: r.feature, bbox: r.bbox })),
|
||||
...appendedSoFar.filter((a) => bBoxesOverlap(a.bbox, pBBox)).map((a) => ({ feature: polygon(a.part), bbox: a.bbox })),
|
||||
];
|
||||
if (targets.length === 0) {
|
||||
fillable.push({ piece, aKm2, cm: pieceCm });
|
||||
continue;
|
||||
}
|
||||
let cur = { type: "Feature", properties: {}, geometry: piecePoly.geometry };
|
||||
let curBBox = pBBox;
|
||||
let covered = false;
|
||||
for (const t of targets) {
|
||||
if (!bBoxesOverlap(curBBox, t.bbox)) continue;
|
||||
let d;
|
||||
try {
|
||||
d = difference(featureCollection([cur, t.feature]));
|
||||
} catch {
|
||||
clipFailN++; // clip against this target failed: keep current (safe fallback)
|
||||
continue;
|
||||
}
|
||||
if (!d || !d.geometry) { covered = true; break; } // piece fully covered by this target
|
||||
cur = { type: "Feature", properties: {}, geometry: d.geometry };
|
||||
curBBox = bbox(cur);
|
||||
}
|
||||
if (covered || !cur.geometry) { clippedKm2 += aKm2; continue; } // fully covered by foreign territory
|
||||
const kept = [];
|
||||
for (const cp of partsOf(cur.geometry, iso)) {
|
||||
const cpArea = area(polygon(cp)) * 1e-6;
|
||||
if (cpArea < GAP_MIN_KM2) { sliverKm2 += cpArea; sliverN++; continue; }
|
||||
if (existingPartSet.has(JSON.stringify(cp))) { dupN++; continue; }
|
||||
kept.push({ piece: cp, aKm2: cpArea, cm: centroid(polygon(cp)).geometry.coordinates });
|
||||
}
|
||||
clippedKm2 += Math.max(0, aKm2 - kept.reduce((s, k) => s + k.aKm2, 0));
|
||||
fillable.push(...kept);
|
||||
}
|
||||
|
||||
// Pass 2: nearest-region assignment and append.
|
||||
const regionInfo = countryRegions.map((r) => {
|
||||
const f = feature(r.geometry);
|
||||
return { r, cm: centroid(f).geometry.coordinates, areaKm2: area(f) * 1e-6 };
|
||||
});
|
||||
let realKm2 = 0;
|
||||
for (const { piece, aKm2, cm } of fillable) {
|
||||
// Nearest region by centroid haversine distance; tie → larger area, then lexicographic id.
|
||||
let best = null;
|
||||
for (const info of regionInfo) {
|
||||
const d = distance(cm, info.cm);
|
||||
if (
|
||||
best === null ||
|
||||
d < best.d ||
|
||||
(d === best.d && (info.areaKm2 > best.areaKm2 || (info.areaKm2 === best.areaKm2 && info.r.id < best.id)))
|
||||
) best = { info, d };
|
||||
}
|
||||
if (!best) continue; // no regions at all (should not happen)
|
||||
realKm2 += aKm2;
|
||||
const target = best.info.r;
|
||||
const parts = (target.geometry.type === "MultiPolygon" ? target.geometry.coordinates : [target.geometry.coordinates]).concat([piece]);
|
||||
target.geometry = toGeometry(parts);
|
||||
const a = added.get(target) ?? { km2: 0, parts: 0 };
|
||||
a.km2 += aKm2; a.parts += 1;
|
||||
added.set(target, a);
|
||||
appendedSoFar.push({ parent: iso, bbox: partBBox(piece), part: piece });
|
||||
}
|
||||
|
||||
totalSliverKm2 += sliverKm2;
|
||||
totalWaterKm2 += waterKm2;
|
||||
totalFilledKm2 += realKm2;
|
||||
totalDupN += dupN;
|
||||
totalClippedKm2 += clippedKm2;
|
||||
totalClipFailN += clipFailN;
|
||||
|
||||
// Verify the fill for countries we actually changed: recompute the residual gap.
|
||||
let afterKm2 = null;
|
||||
if (realKm2 > 0) {
|
||||
try {
|
||||
const u2 = countryRegions.length === 1
|
||||
? countryRegions[0].geometry
|
||||
: union(featureCollection(countryRegions.map((r) => feature(r.geometry)))).geometry;
|
||||
afterKm2 = area(difference(featureCollection([landmass, { ...landmass, geometry: u2 }]))) * 1e-6;
|
||||
} catch { afterKm2 = null; }
|
||||
}
|
||||
console.log(
|
||||
`${iso}\tregions=${countryRegions.length}\tgap-before=${(area(diffGeom) * 1e-6).toFixed(0)}` +
|
||||
(afterKm2 === null ? "" : `\tgap-after=${afterKm2.toFixed(0)}`) +
|
||||
`\tfilled=${realKm2.toFixed(0)}\tslivers=${sliverN}(${sliverKm2.toFixed(0)})\twater-skipped=${waterN}(${waterKm2.toFixed(0)})` +
|
||||
`\tdup-skipped=${dupN}${clippedKm2 > 0 ? `\tforeign-overlap-removed=${clippedKm2.toFixed(0)}` : ""}${clipFailN > 0 ? `\tclip-failed=${clipFailN}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const totalAddedKm2 = [...added.values()].reduce((s, a) => s + a.km2, 0);
|
||||
const totalAddedParts = [...added.values()].reduce((s, a) => s + a.parts, 0);
|
||||
console.log(`\nfilled ${totalAddedKm2.toFixed(0)} km² into ${added.size} regions (+${totalAddedParts} parts)`);
|
||||
console.log(
|
||||
`totals: filled=${totalFilledKm2.toFixed(0)} slivers-ignored=${totalSliverKm2.toFixed(0)} water-kept-open=${totalWaterKm2.toFixed(0)}` +
|
||||
` duplicate-phantoms-skipped=${totalDupN} foreign-overlap-removed=${totalClippedKm2.toFixed(0)} clip-failed=${totalClipFailN}`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(outFile, JSON.stringify(geo));
|
||||
console.log(`wrote ${outFile}`);
|
||||
+178
-51
@@ -12,9 +12,16 @@
|
||||
* Tiny or uncovered countries collapse to a single whole-country region
|
||||
* (`<ISO_A3>-01`).
|
||||
*
|
||||
* This test is self-contained (no import of the generator): it re-implements
|
||||
* the same planar area model the generator/analysis use, so the area-based
|
||||
* assertions measure the same quantity:
|
||||
* v0.17 (`scripts/fill-gaps.mjs`) additionally fills the no-one's-land left
|
||||
* between merged sub-regions: per country, the uncovered slivers of the
|
||||
* country's landmass (≥ 25 km²) were added as extra parts of the nearest
|
||||
* region, so the regions now cover the landmass almost completely (the
|
||||
* sampling test below measures that per country).
|
||||
*
|
||||
* This test is self-contained (no import of the generator) and deliberately
|
||||
* dependency-free — it re-implements the same planar area model the
|
||||
* generator/analysis use, so the area-based assertions measure the same
|
||||
* quantity:
|
||||
*
|
||||
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
|
||||
*
|
||||
@@ -22,6 +29,11 @@
|
||||
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
|
||||
* by design, the same way the coverage band already absorbed them.
|
||||
*
|
||||
* No geojson/@turf types are imported: the project keeps `@types/geojson`
|
||||
* OUT of the tsc program (see tsconfig `types`) so maplibre-gl's d.ts stays
|
||||
* in its loose (skipLibCheck) mode; the one coverage test below uses an
|
||||
* inline even-odd ray cast instead of a geometry library.
|
||||
*
|
||||
* It reads each file exactly once and runs in O(total vertices).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -77,6 +89,50 @@ function median(nums: number[]): number {
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
}
|
||||
|
||||
/** Raw min/max bbox [w, s, e, n] of a geometry (all coordinates). */
|
||||
function rawBBox(geom: Geometry): [number, number, number, number] {
|
||||
const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
|
||||
for (const poly of polys)
|
||||
for (const ring of poly)
|
||||
for (const [lo, la] of ring) {
|
||||
if (lo < w) w = lo;
|
||||
if (lo > e) e = lo;
|
||||
if (la < s) s = la;
|
||||
if (la > n) n = la;
|
||||
}
|
||||
return [w, s, e, n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Even-odd ray cast: is `[lon, lat]` inside `ring`? Planar, in raw lon/lat
|
||||
* space (no antimeridian handling — the polygons and the sample points live
|
||||
* in the same unwrapped space, so containment is mathematically consistent
|
||||
* even for countries that cross the date line). Used only as a fast,
|
||||
* deterministic coverage proxy (see the sampling test), never for areas.
|
||||
*/
|
||||
function pointInRing(pt: [number, number], ring: Ring): boolean {
|
||||
const [x, y] = pt;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Is a point inside a (Multi)Polygon: inside the outer ring, outside all holes. */
|
||||
function pointInGeom(pt: [number, number], geom: Geometry): boolean {
|
||||
if (geom.type === 'Polygon') {
|
||||
if (!pointInRing(pt, geom.coordinates[0])) return false;
|
||||
return geom.coordinates.slice(1).every((h) => !pointInRing(pt, h));
|
||||
}
|
||||
if (geom.type === 'MultiPolygon')
|
||||
return geom.coordinates.some((p) => pointInGeom(pt, { type: 'Polygon', coordinates: p }));
|
||||
return false;
|
||||
}
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
interface RegionFeature {
|
||||
@@ -112,6 +168,9 @@ for (const f of regions.features)
|
||||
const countByParent = new Map<string, number>();
|
||||
for (const f of regions.features)
|
||||
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
||||
// Raw bboxes for the sampling test's cheap reject (O(total vertices)).
|
||||
const regionBB = new Map<string, [number, number, number, number]>();
|
||||
for (const f of regions.features) regionBB.set(f.id, rawBBox(f.geometry));
|
||||
|
||||
// --- island/close-neighbor helpers (mirror scripts/coarsen-regions.mjs) ------
|
||||
|
||||
@@ -326,56 +385,54 @@ describe('regions.geo.json', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('has no small enclosed holes left (the v0.15 cleanup filled them); only real water bodies remain', () => {
|
||||
// v0.15 cleanup (scripts/cleanup-regions.mjs): every enclosed hole (inner
|
||||
// ring) smaller than the threshold was dropped so each region is solid;
|
||||
// only genuine water bodies (holes >= the threshold) survive. The big real
|
||||
// lakes (Great Lakes, Caspian, Aral, Lake Superior, …) were already
|
||||
// dissolved by the v0.14 coarsening, so the surviving holes are few and
|
||||
// large. This guards the fix: a re-introduction of the tiny artifacts
|
||||
// (thousands of < threshold holes) fails here.
|
||||
const HOLE_MIN_KM2 = 1000; // keep in sync with cleanup-regions.mjs HOLE_AREA_MAX_KM2
|
||||
let holeCount = 0;
|
||||
let smallHoles = 0;
|
||||
it('leaves exactly one enclosed hole: KZ-KZY (the real Aral Sea), still large', () => {
|
||||
// v0.15 filled every small hole; v0.16 smoothing never touches holes; and
|
||||
// v0.17's gap-filler explicitly keeps the Aral hole (its centroid matches a
|
||||
// pre-existing hole and it lies inside another country's landmass) while
|
||||
// dropping phantom holes. So the whole file must contain exactly one hole,
|
||||
// on KZ-KZY, at a realistic size (~5,860 km2 planar; the real Aral has
|
||||
// shrunk to roughly this by the 2020s) — no new holes of any size.
|
||||
let holes = 0;
|
||||
let maxHole = 0;
|
||||
const holeIds: string[] = [];
|
||||
let kzkzyHoleArea = 0;
|
||||
for (const f of regions.features) {
|
||||
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
|
||||
for (const poly of polys) {
|
||||
for (let i = 1; i < poly.length; i++) {
|
||||
const hole = poly[i];
|
||||
holes++;
|
||||
let lat = 0;
|
||||
for (const [, la] of hole) lat += la;
|
||||
const area = ringAreaKm2(hole, hole.length ? lat / hole.length : 0);
|
||||
holeCount++;
|
||||
for (const [, la] of poly[i]) lat += la;
|
||||
const area = ringAreaKm2(poly[i], poly[i].length ? lat / poly[i].length : 0);
|
||||
if (area > maxHole) maxHole = area;
|
||||
if (area < HOLE_MIN_KM2) {
|
||||
smallHoles++;
|
||||
if (smallHoles <= 10) console.log(` small hole ${f.id} ≈ ${Math.round(area)} km2`);
|
||||
}
|
||||
holeIds.push(f.id);
|
||||
if (f.id === 'KZ-KZY') kzkzyHoleArea = area;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(holes, `expected exactly 1 hole (KZ-KZY), found ${holes}: ${[...new Set(holeIds)].join(', ')}`).toBe(1);
|
||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||
expect(
|
||||
smallHoles,
|
||||
`${smallHoles} enclosed hole(s) below ${HOLE_MIN_KM2} km2 remain — the v0.15 cleanup should have filled them`,
|
||||
).toBe(0);
|
||||
expect(
|
||||
holeCount <= 20,
|
||||
`${holeCount} holes remain — expected only a handful of real water bodies`,
|
||||
kzkzyHoleArea >= 5_000 && kzkzyHoleArea <= 7_000,
|
||||
`KZ-KZY hole is ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2 — outside the 5,000–7,000 km2 Aral band`,
|
||||
).toBe(true);
|
||||
console.log(
|
||||
`\n enclosed holes: ${holeCount} remain (all >= ${HOLE_MIN_KM2} km2, largest ${Math.round(maxHole).toLocaleString('en-US')} km2) — small artifacts filled by the v0.15 cleanup`,
|
||||
`\n holes: exactly 1 (KZ-KZY ≈ ${Math.round(kzkzyHoleArea).toLocaleString('en-US')} km2) — the Aral hole survived v0.17's fill`,
|
||||
);
|
||||
});
|
||||
|
||||
it('sums to within [40%, 175%] of each country area (median in [70%, 130%])', () => {
|
||||
it('sums to within [40%, 190%] of each country area (median in [70%, 130%])', () => {
|
||||
// The band is a planar-approximation sanity check. It is wider than a naive
|
||||
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
|
||||
// independently of the admin-0 country polygons (they are NOT clipped to
|
||||
// them), so for a handful of small/island nations the region union
|
||||
// over/under-fills the country: measured range is ~[52%, 151%]
|
||||
// (Brunei under-fills, Vanuatu over-fills). The band brackets that with
|
||||
// margin while still catching a genuinely missing or doubled-up region.
|
||||
// them). v0.17's gap fill removed the large UNDER-fills (the old minimum
|
||||
// was Brunei at ~52% — its regions now cover the landmass, ≈ 79%), but
|
||||
// the OVER-fill tail is a pre-existing admin-1 artifact: a few tiny island
|
||||
// nations (Vanuatu 178.9%, Solomon Is. 133%, Bahamas 129%) have admin-1
|
||||
// polygons that extend beyond their own country polygon, and the fill
|
||||
// only added area *inside* the country, so it can't shrink that tail.
|
||||
// The band brackets the measured [78.8%, 178.9%] with margin while still
|
||||
// catching a genuinely missing or doubled-up region.
|
||||
const ratios: number[] = [];
|
||||
let minRatio = Infinity;
|
||||
let maxRatio = -Infinity;
|
||||
@@ -389,8 +446,8 @@ describe('regions.geo.json', () => {
|
||||
if (ratio < minRatio) { minRatio = ratio; minId = c.id; }
|
||||
if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; }
|
||||
expect(
|
||||
ratio >= 0.4 && ratio <= 1.75,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`,
|
||||
ratio >= 0.4 && ratio <= 1.9,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 190%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
const medRatio = median(ratios);
|
||||
@@ -401,18 +458,88 @@ describe('regions.geo.json', () => {
|
||||
console.log(
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
|
||||
` / median ${(100 * medRatio).toFixed(1)}%` +
|
||||
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–175%)`,
|
||||
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–190%)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('is at the Douglas–Peucker 50m fixed point: 1,796 regions, topology unchanged, 111,175 vertices (v0.16 smoothing)', () => {
|
||||
// v0.16 (scripts/smooth-regions.mjs) thinned every ring with Douglas–Peucker at
|
||||
// ~50 m. That removes redundant vertices only — it never moves a border, merges
|
||||
// a part, or creates/removes a ring — so the topology signature is invariant
|
||||
// (1,796 features, 5,026 parts, 5,027 rings, the single KZ-KZY hole) and the
|
||||
// vertex total sits at the pass's fixed point (re-running changes nothing).
|
||||
// Pinning it guards against an accidental re-coarsening or a different-tolerance
|
||||
// re-smooth silently changing the shipped dataset.
|
||||
it('covers each country\'s landmass with no significant no-one\'s-land (v0.17 gap fill, grid-sampled)', () => {
|
||||
// The v0.17 fill (scripts/fill-gaps.mjs) assigned every uncovered sliver of
|
||||
// each country's landmass (≥ 25 km²) to its nearest region, so the residual
|
||||
// no-one's-land per country should be small everywhere. Measuring the
|
||||
// exact union is too slow, so this samples a 25×25 grid over each
|
||||
// country's raw bbox (skipping points outside the country polygon) and
|
||||
// counts the fraction of in-land sample points that fall in NONE of the
|
||||
// country's regions — a fast, deterministic proxy (same planar space,
|
||||
// inline even-odd ray cast; antimeridian-safe because points and polygons
|
||||
// share the raw coordinate space). Sampling over-counts coastline
|
||||
// mismatch (admin-0 vs admin-1 edges), so the thresholds are generous:
|
||||
// they catch a real regression (a whole sliver left unfilled) without
|
||||
// being brittle to digitization fuzz.
|
||||
const N = 25;
|
||||
const results: { id: string; name: string; frac: number; land: number }[] = [];
|
||||
for (const c of countries.features) {
|
||||
const [w, s, e, n] = rawBBox(c.geometry);
|
||||
if (e - w < 0.01 || n - s < 0.01) continue; // degenerate (point-like) country
|
||||
const regs = regions.features.filter((f) => f.properties.parent === c.id);
|
||||
let land = 0;
|
||||
let uncov = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const la = s + ((i + 0.5) / N) * (n - s);
|
||||
for (let j = 0; j < N; j++) {
|
||||
const lo = w + ((j + 0.5) / N) * (e - w);
|
||||
const pt: [number, number] = [lo, la];
|
||||
if (!pointInGeom(pt, c.geometry)) continue;
|
||||
land++;
|
||||
// cheap bbox reject first, then the full containment test
|
||||
if (!regs.some((f) => {
|
||||
const [rw, rs, re, rn] = regionBB.get(f.id)!;
|
||||
if (lo < rw || lo > re || la < rs || la > rn) return false;
|
||||
return pointInGeom(pt, f.geometry);
|
||||
}))
|
||||
uncov++;
|
||||
}
|
||||
}
|
||||
if (land >= 4) results.push({ id: c.id, name: c.properties.name, frac: uncov / land, land });
|
||||
}
|
||||
expect(results.length, `only ${results.length} countries sampled (expected ~176)`).toBeGreaterThanOrEqual(150);
|
||||
const fracs = results.map((r) => r.frac).sort((a, b) => a - b);
|
||||
const med = median(fracs);
|
||||
const p90 = fracs[Math.floor(fracs.length * 0.9)];
|
||||
const worst = results.reduce((m, r) => (r.frac > m.frac ? r : m));
|
||||
const over20 = results.filter((r) => r.frac > 0.2).sort((a, b) => b.frac - a.frac);
|
||||
expect(
|
||||
med <= 0.05,
|
||||
`median country gap ${(100 * med).toFixed(1)}% > 5% — significant no-one's-land remains`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
p90 <= 0.15,
|
||||
`p90 country gap ${(100 * p90).toFixed(1)}% > 15% — many countries have real gaps`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
worst.frac <= 0.4,
|
||||
`worst country ${worst.id} (${worst.name}) gap ${(100 * worst.frac).toFixed(1)}% > 40%`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
over20.length <= 3,
|
||||
`${over20.length} countries with > 20% gap: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}`,
|
||||
).toBe(true);
|
||||
console.log(
|
||||
`\n no-one's-land (grid-sampled, N=${N}): median ${(100 * med).toFixed(1)}% / p90 ${(100 * p90).toFixed(1)}%` +
|
||||
` / max ${worst.id} (${worst.name}) ${(100 * worst.frac).toFixed(1)}%` +
|
||||
(over20.length ? ` — >20%: ${over20.map((r) => `${r.id} ${(100 * r.frac).toFixed(1)}%`).join(', ')}` : ' — no country > 20%'),
|
||||
);
|
||||
});
|
||||
|
||||
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
|
||||
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
|
||||
// fixed point: it never moves an existing border, merges a part, or
|
||||
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
|
||||
// keeps the single KZ-KZY hole. The region count (1,796), the
|
||||
// per-feature ids/names/parents and their order are unchanged; only the
|
||||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
||||
// the signature guards against a non-idempotent or destructive re-run.
|
||||
// The fill is byte-deterministic per input and idempotent (a second run
|
||||
// on the filled file changes nothing).
|
||||
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||
let verts = 0,
|
||||
rings = 0,
|
||||
@@ -429,15 +556,15 @@ describe('regions.geo.json', () => {
|
||||
for (const ring of poly) verts += ring.length;
|
||||
}
|
||||
}
|
||||
expect(regions.features.length, 'region count').toBe(1796);
|
||||
expect(verts, 'total vertices (incl. ring closure)').toBe(111_175); // was 111,891 pre-v0.16; 716 removed
|
||||
expect(parts, 'parts').toBe(5026); // unchanged — no part merged/dropped
|
||||
expect(rings, 'rings').toBe(5027); // unchanged — no ring added/dropped
|
||||
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
||||
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
||||
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
|
||||
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
|
||||
console.log(
|
||||
`\n v0.16 smoothing: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||||
`${holes} hole (KZ-KZY), ${verts} vertices (50m DP fixed point; 716 removed from 111,891) — topology unchanged`,
|
||||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
||||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { migrateTerritoryToRegions } from './regions';
|
||||
|
||||
export interface Db {
|
||||
save(state: GameState): void;
|
||||
@@ -39,7 +40,11 @@ export function openDb(filePath: string): Db {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as GameState;
|
||||
// v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
|
||||
// sub-region ids on read. A no-op when the region geo is not loaded,
|
||||
// and idempotent once a state has been migrated (the next save then
|
||||
// persists the region-level territory).
|
||||
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+105
-11
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { _setRegionsForTests } from './regions';
|
||||
import {
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
@@ -24,6 +25,27 @@ function fakeLlm(responses: string[]): LlmClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Give the region module a small, deterministic index so `buildUserPrompt`'s
|
||||
// `regions` reference table is populated in these fast unit tests (the real
|
||||
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
|
||||
// below.
|
||||
beforeAll(() => {
|
||||
_setRegionsForTests(
|
||||
{
|
||||
'GB-01': { name: 'Greater London', parent: 'GBR' },
|
||||
'GB-02': { name: 'Scotland', parent: 'GBR' },
|
||||
'US-01': { name: 'New England', parent: 'USA' },
|
||||
'US-02': { name: 'The Midwest', parent: 'USA' },
|
||||
'PL-01': { name: 'Pomerania', parent: 'POL' },
|
||||
'DE-01': { name: 'Rhineland', parent: 'DEU' },
|
||||
},
|
||||
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
|
||||
);
|
||||
});
|
||||
|
||||
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
|
||||
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
|
||||
// stubbed gain of that region has a prior owner to move it from.
|
||||
const preset: Preset = {
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
@@ -37,7 +59,7 @@ const preset: Preset = {
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR'],
|
||||
territory: ['GB-01', 'GB-02'],
|
||||
relations: { us: 'neutral', de: 'hostile' },
|
||||
},
|
||||
{
|
||||
@@ -45,7 +67,7 @@ const preset: Preset = {
|
||||
name: 'US',
|
||||
type: 'major',
|
||||
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
||||
territory: ['USA'],
|
||||
territory: ['US-01', 'US-02', 'PL-01'],
|
||||
relations: { uk: 'neutral' },
|
||||
},
|
||||
{
|
||||
@@ -53,7 +75,7 @@ const preset: Preset = {
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
||||
territory: ['DEU'],
|
||||
territory: ['DE-01'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
@@ -88,7 +110,7 @@ const validResult = JSON.stringify({
|
||||
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
@@ -109,8 +131,9 @@ describe('runTurn', () => {
|
||||
// relations are symmetric
|
||||
expect(state.nations.uk.relations.us).toBe('ally');
|
||||
expect(state.nations.us.relations.uk).toBe('ally');
|
||||
// territory moved to DE
|
||||
expect(state.nations.de.territory).toContain('POL');
|
||||
// the region moved to DE, out of its prior owner (us)
|
||||
expect(state.nations.de.territory).toContain('PL-01');
|
||||
expect(state.nations.us.territory).not.toContain('PL-01');
|
||||
// one event appended, tagged with the turn that was resolved (1)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].turn).toBe(1);
|
||||
@@ -121,7 +144,8 @@ describe('runTurn', () => {
|
||||
// and the original state object is NOT mutated
|
||||
expect(original.turn).toBe(1);
|
||||
expect(original.nations.uk.stats.military).toBe(80);
|
||||
expect(original.nations.de.territory).not.toContain('POL');
|
||||
expect(original.nations.us.territory).toContain('PL-01');
|
||||
expect(original.nations.de.territory).not.toContain('PL-01');
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values into 0-100', async () => {
|
||||
@@ -191,7 +215,7 @@ describe('runTurn', () => {
|
||||
});
|
||||
|
||||
describe('runTurn (batched round, deterministic stub)', () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5), one deterministic region gain', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
|
||||
const { state, result } = await runTurn({
|
||||
@@ -209,6 +233,12 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
expect(state.events[0].outcome).toBe('success');
|
||||
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
|
||||
// the stub's one deterministic per-turn territory move: the player annexes
|
||||
// the lexicographically-first region of the first other nation (us holds
|
||||
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
|
||||
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
|
||||
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
||||
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
// committing a turn is a new revision too
|
||||
expect(state.version).toBe(2);
|
||||
// the original object is not mutated (a fresh queue was consumed)
|
||||
@@ -216,7 +246,7 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(original.version).toBe(1);
|
||||
});
|
||||
|
||||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||||
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => {
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
@@ -230,9 +260,13 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(state.events[0].type).toBe('political');
|
||||
expect(state.events[0].title).toBe('The world moves on');
|
||||
expect(state.events[0].outcome).toBe('neutral');
|
||||
// an empty round makes no move: every territory is untouched
|
||||
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
|
||||
expect(state.nations.de.territory).toEqual(['DE-01']);
|
||||
});
|
||||
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
|
||||
@@ -251,6 +285,9 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
|
||||
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
|
||||
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
|
||||
// …and one (not three) territory move for the whole batch
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
|
||||
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,6 +301,7 @@ describe('buildUserPrompt (batched round)', () => {
|
||||
pending: RoundItem[];
|
||||
playerActions: unknown[];
|
||||
roundInstruction: string;
|
||||
regions: Record<string, string>;
|
||||
};
|
||||
expect(parsed.pending).toEqual(items);
|
||||
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
|
||||
@@ -271,6 +309,20 @@ describe('buildUserPrompt (batched round)', () => {
|
||||
expect(parsed.roundInstruction).toContain('Propose a pact');
|
||||
});
|
||||
|
||||
it('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
regions: Record<string, string>;
|
||||
state: { nations: Record<string, { territory: string[] }> };
|
||||
};
|
||||
// the table is keyed by the union of all nations' (sub-region) territory ids
|
||||
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
|
||||
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
|
||||
// each id maps to a human "name (parent)" label from the region index
|
||||
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
|
||||
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
|
||||
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
|
||||
});
|
||||
|
||||
it('frames an empty round as "the world advances on its own momentum"', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
pending: RoundItem[];
|
||||
@@ -461,6 +513,48 @@ describe('applyTurnResult', () => {
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('moves a gained region out of its prior owner (region-level transfer)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.territory).toContain('PL-01');
|
||||
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
});
|
||||
|
||||
it('adds a region nobody owned (no prior owner to splice from)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']);
|
||||
});
|
||||
|
||||
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
|
||||
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
|
||||
+13
-2
@@ -24,6 +24,7 @@ import {
|
||||
} from '../shared/protocol';
|
||||
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
|
||||
import { ADVISOR_MARKER } from './stub';
|
||||
import { regionNamesFor } from './regions';
|
||||
|
||||
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||||
@@ -53,11 +54,12 @@ export function buildSystemPrompt(preset: Preset): string {
|
||||
' ],',
|
||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<regionId>"],"lose":["<regionId>"]}]',
|
||||
'}',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
||||
'- Use only nation ids and sub-region ids (e.g. `FR-57`) that exist in the provided state.',
|
||||
'- A region id (e.g. `FR-57`) identifies a single sub-region — the `regions` table below lists each id with its name and country; a nation\'s border can change one region at a time.',
|
||||
'- 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.',
|
||||
@@ -106,6 +108,15 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
||||
outcome: e.outcome,
|
||||
})),
|
||||
},
|
||||
// A name lookup for every region id referenced anywhere in the state, so the
|
||||
// model can map an id it is considering (e.g. `FR-57`) to a place it
|
||||
// recognizes. This is a top-level reference table keyed by sub-region id;
|
||||
// each nation's own `territory` still carries raw ids (via nationForPrompt,
|
||||
// unchanged) so the deterministic stub can parse them. When the region geo
|
||||
// is not loaded the table is empty and the model falls back to the raw ids.
|
||||
regions: regionNamesFor(
|
||||
Object.values(state.nations).flatMap((n) => n.territory),
|
||||
),
|
||||
playerActions,
|
||||
pending: items,
|
||||
roundInstruction,
|
||||
|
||||
+15
-1
@@ -19,6 +19,7 @@ import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||
import { expandTerritory, loadRegions } from './regions';
|
||||
import { buildRegionMap } from './map';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
@@ -41,6 +42,10 @@ function findRoot(): string {
|
||||
}
|
||||
|
||||
const root = findRoot();
|
||||
// Load the sub-region geo once at boot (a graceful no-op, with a warning, when
|
||||
// the file is missing — the territory model then degrades to whole-country
|
||||
// ISO_A3 codes). Must run before the first game creation/load.
|
||||
loadRegions(root);
|
||||
const presetsDir = path.join(root, 'presets');
|
||||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||
@@ -116,7 +121,16 @@ app.post('/api/games', (req: Request, res) => {
|
||||
playerNationId: nation.id,
|
||||
turn: 1,
|
||||
date: preset.startDate,
|
||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||
// v0.19: territory is at sub-region granularity — expand each preset
|
||||
// nation's ISO_A3 country codes into all their sub-region ids (a no-op that
|
||||
// keeps the country codes when the region geo is not loaded).
|
||||
nations: Object.fromEntries(
|
||||
preset.nations.map((n) => {
|
||||
const nation = deepClone(n);
|
||||
nation.territory = expandTerritory(nation.territory);
|
||||
return [n.id, nation];
|
||||
}),
|
||||
),
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1, // the first saved revision; every later mutation bumps it
|
||||
|
||||
+45
-42
@@ -3,6 +3,8 @@ import { buildRegionMap } from './map';
|
||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
|
||||
// not whole-country ISO_A3 codes.
|
||||
const state: GameState = {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
@@ -16,7 +18,7 @@ const state: GameState = {
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR', 'IRL'],
|
||||
territory: ['GB-01', 'GB-02'],
|
||||
relations: {},
|
||||
},
|
||||
de: {
|
||||
@@ -24,7 +26,7 @@ const state: GameState = {
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||
territory: ['DEU'],
|
||||
territory: ['DE-01'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
@@ -33,28 +35,29 @@ const state: GameState = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
||||
// The RegionMap built from the fixture: the authoritative sub-region id → owner
|
||||
// source.
|
||||
const regions: RegionMap = buildRegionMap(state);
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
||||
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
|
||||
const m = buildRegionMap(state);
|
||||
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||
// a region nobody owns is simply absent
|
||||
expect(m.FRA).toBeUndefined();
|
||||
expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||
// a sub-region nobody owns is simply absent
|
||||
expect(m['FR-57']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regionOwner', () => {
|
||||
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
||||
expect(regionOwner(regions, 'GBR')).toEqual({
|
||||
it('resolves an owned sub-region id to its owner record', () => {
|
||||
expect(regionOwner(regions, 'GB-01')).toEqual({
|
||||
color: nationColor('uk'),
|
||||
owner: 'UK',
|
||||
ownerId: 'uk',
|
||||
});
|
||||
expect(regionOwner(regions, 'DEU')).toEqual({
|
||||
expect(regionOwner(regions, 'DE-01')).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
ownerId: 'de',
|
||||
@@ -62,11 +65,11 @@ describe('regionOwner', () => {
|
||||
});
|
||||
|
||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'US-01')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty or missing code', () => {
|
||||
it('returns undefined for an empty or missing id', () => {
|
||||
expect(regionOwner(regions, '')).toBeUndefined();
|
||||
expect(regionOwner(regions, null)).toBeUndefined();
|
||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||
@@ -74,28 +77,28 @@ describe('regionOwner', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* #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.
|
||||
* #6 regression guard (sub-national map, v0.19 region-level ownership). 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`. Since v0.19 ownership is at **sub-region**
|
||||
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
|
||||
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
|
||||
* is keyed by that id). This pins the exact data shape and proves the
|
||||
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
|
||||
* repacked numeric `feature.id` path does not.
|
||||
*/
|
||||
describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => {
|
||||
describe('#6 — region owner must be resolved from properties.regionId, never the 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).
|
||||
// is a number (repacked), and the stable identifiers live only in properties
|
||||
// — `regionId` (the sub-region id, the RegionMap key) and `parent` (the
|
||||
// static parent country's ISO_A3).
|
||||
const repackedFeature = {
|
||||
id: 0,
|
||||
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
|
||||
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
|
||||
};
|
||||
|
||||
it('resolves the owner from properties.parent (the fix)', () => {
|
||||
it('resolves the owner from properties.regionId (the fix)', () => {
|
||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
@@ -103,30 +106,30 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('the repacked numeric feature.id does NOT resolve — the original 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();
|
||||
});
|
||||
|
||||
it('a sub-region id nobody owns does NOT resolve', () => {
|
||||
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFeatureOwner', () => {
|
||||
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
|
||||
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
|
||||
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();
|
||||
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a parent country nobody owns', () => {
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
|
||||
it('returns undefined for a sub-region nobody owns', () => {
|
||||
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+9
-5
@@ -1,6 +1,10 @@
|
||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
||||
// Build the choropleth payload from a GameState: every **sub-region id** in a
|
||||
// nation's territory maps to its owning nation plus that nation's stable color
|
||||
// (see shared.nationColor). The client merges this onto public/regions.geo.json
|
||||
// and calls source.setData(). The map is keyed by whatever ids sit in
|
||||
// `nation.territory` — sub-region ids (e.g. `FR-57`) as of v0.19 — so it needs
|
||||
// no change to move from country to sub-region granularity; a sub-region in no
|
||||
// nation's territory is simply absent (unowned, rendered grey by the map).
|
||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
@@ -8,8 +12,8 @@ export function buildRegionMap(state: GameState): RegionMap {
|
||||
const map: RegionMap = {};
|
||||
for (const [id, nation] of Object.entries(state.nations)) {
|
||||
const color = nationColor(id);
|
||||
for (const code of nation.territory) {
|
||||
map[code] = { color, owner: nation.name, ownerId: id };
|
||||
for (const regionId of nation.territory) {
|
||||
map[regionId] = { color, owner: nation.name, ownerId: id };
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* server/regions.test.ts — the sub-region data module (v0.19).
|
||||
*
|
||||
* The first suite loads the REAL `public/regions.geo.json` (the same file the
|
||||
* production server loads at boot), so expansion/migration runs against the
|
||||
* actual 1,796-region dataset. The second suite exercises the graceful
|
||||
* "geo missing" path (module not loaded) via the `_resetRegionsForTests` seam,
|
||||
* and pins the `_setRegionsForTests` fixture seam used by the other unit tests.
|
||||
* Module state is per vitest file, so these suites never collide with other
|
||||
* test files.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { GameState } from '../shared/types';
|
||||
import {
|
||||
_resetRegionsForTests,
|
||||
_setRegionsForTests,
|
||||
expandTerritory,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
migrateTerritoryToRegions,
|
||||
regionLabel,
|
||||
regionNamesFor,
|
||||
} from './regions';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
function makeState(territory: string[]): GameState {
|
||||
return {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: 'uk',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations: {
|
||||
uk: {
|
||||
id: 'uk',
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory,
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('server/regions (real geo)', () => {
|
||||
beforeAll(() => {
|
||||
_resetRegionsForTests();
|
||||
loadRegions(root);
|
||||
});
|
||||
|
||||
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
|
||||
expect(hasRegionData()).toBe(true);
|
||||
});
|
||||
|
||||
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
|
||||
loadRegions(root);
|
||||
expect(hasRegionData()).toBe(true);
|
||||
});
|
||||
|
||||
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
|
||||
const ids = expandTerritory(['FRA']);
|
||||
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
|
||||
expect(new Set(ids).size).toBe(ids.length); // deduped
|
||||
// every expanded id resolves back to the same parent country
|
||||
const names = regionNamesFor(ids);
|
||||
for (const id of ids) expect(names[id]).toMatch(/\(FRA\)$/);
|
||||
});
|
||||
|
||||
it('expandTerritory is order-preserving and dedupes repeated country codes', () => {
|
||||
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
|
||||
// two countries: the union, in first-seen order (the ids are disjoint)
|
||||
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
|
||||
...expandTerritory(['FRA']),
|
||||
...expandTerritory(['DEU']),
|
||||
]);
|
||||
});
|
||||
|
||||
it('expandTerritory of an empty or unknown list yields no regions', () => {
|
||||
expect(expandTerritory([])).toEqual([]);
|
||||
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
|
||||
});
|
||||
|
||||
it('regionNamesFor maps every known id to "name (parent)"', () => {
|
||||
const ids = expandTerritory(['DEU']);
|
||||
const names = regionNamesFor(ids);
|
||||
expect(Object.keys(names).sort()).toEqual([...ids].sort());
|
||||
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
|
||||
});
|
||||
|
||||
it('regionNamesFor skips ids that are not in the loaded index', () => {
|
||||
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
|
||||
});
|
||||
|
||||
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
|
||||
const [id] = expandTerritory(['FRA']);
|
||||
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
|
||||
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
|
||||
});
|
||||
|
||||
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
|
||||
const [deId] = expandTerritory(['DEU']); // a real region id of another country
|
||||
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
|
||||
const originalArray = state.nations.uk.territory;
|
||||
// it mutates and returns the (fresh) state it is given — callers pass a
|
||||
// just-parsed object (db.load), so the in-place mutation is safe
|
||||
const out = migrateTerritoryToRegions(state);
|
||||
expect(out).toBe(state);
|
||||
// the original array object is never mutated in place — a new one is
|
||||
// installed on the nation, and the original stays as it was
|
||||
expect(out.nations.uk.territory).not.toBe(originalArray);
|
||||
expect(originalArray).toEqual(['FRA', deId]);
|
||||
// the ISO_A3 entry is gone, replaced by its sub-region ids…
|
||||
expect(out.nations.uk.territory).not.toContain('FRA');
|
||||
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
|
||||
// …and the existing region id survives, exactly once
|
||||
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
|
||||
// idempotent: a second migration changes nothing
|
||||
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server/regions (fallback: geo not loaded)', () => {
|
||||
beforeAll(() => {
|
||||
_resetRegionsForTests();
|
||||
});
|
||||
afterAll(() => {
|
||||
// restore the real geo for anything in this file that runs later
|
||||
_resetRegionsForTests();
|
||||
loadRegions(root);
|
||||
});
|
||||
|
||||
it('hasRegionData is false after a reset', () => {
|
||||
expect(hasRegionData()).toBe(false);
|
||||
});
|
||||
|
||||
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
|
||||
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
|
||||
expect(expandTerritory([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
|
||||
expect(regionNamesFor(['FRA'])).toEqual({});
|
||||
expect(regionLabel('FRA')).toBe('FRA');
|
||||
});
|
||||
|
||||
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
|
||||
const state = makeState(['FRA']);
|
||||
const out = migrateTerritoryToRegions(state);
|
||||
expect(out).toBe(state);
|
||||
expect(out.nations.uk.territory).toEqual(['FRA']);
|
||||
});
|
||||
|
||||
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
|
||||
_setRegionsForTests(
|
||||
{
|
||||
'GB-01': { name: 'Greater London', parent: 'GBR' },
|
||||
'GB-02': { name: 'Scotland', parent: 'GBR' },
|
||||
},
|
||||
{ GBR: ['GB-01', 'GB-02'] },
|
||||
);
|
||||
expect(hasRegionData()).toBe(true);
|
||||
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
|
||||
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
|
||||
const state = makeState(['GBR']);
|
||||
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Sub-national region data: loaded ONCE at server boot from
|
||||
// `public/regions.geo.json` (dev) or `dist/regions.geo.json` (prod — Vite copies
|
||||
// `public/` → `dist/`). It builds two in-memory maps that power the
|
||||
// sub-region-level territory model (v0.19):
|
||||
//
|
||||
// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country)
|
||||
// - `regionsByCountry`: ISO_A3 country → array of its region ids
|
||||
//
|
||||
// These are used to (a) expand a nation's ISO_A3 territory into its sub-region
|
||||
// ids at game creation / load-time migration, and (b) hand the LLM a human
|
||||
// name for each region id it may reference in a TurnResult.
|
||||
//
|
||||
// This is deliberately turf-free (plain `JSON.parse`): importing `@turf/*`
|
||||
// would pull `@types/geojson` into the tsc program and break `WorldMap.tsx`
|
||||
// (see AGENTS.md / the v0.17 changelog note on the geojson type gotcha).
|
||||
//
|
||||
// The server must still boot and the app still work when the geo file is
|
||||
// missing or unreadable: the maps stay empty, `hasRegionData()` stays false,
|
||||
// and the territory model gracefully degrades to whole-country ISO_A3 codes
|
||||
// (the pre-v0.19 behavior). Nothing here ever throws.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
/** A region's stable identity: display name + the parent country's ISO_A3. */
|
||||
export interface RegionInfo {
|
||||
name: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
let regionIndex: Record<string, RegionInfo> = {};
|
||||
let regionsByCountry: Record<string, string[]> = {};
|
||||
let loaded = false;
|
||||
|
||||
/** True once the region geo has been loaded successfully. */
|
||||
export function hasRegionData(): boolean {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)build the in-memory region maps from the region geo. Idempotent: a second
|
||||
* call is a no-op. Tries `<root>/public/regions.geo.json` first (dev), then
|
||||
* `<root>/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`).
|
||||
* If no candidate file is found, or it fails to parse, the maps stay empty and
|
||||
* `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws.
|
||||
*/
|
||||
export function loadRegions(root: string): void {
|
||||
if (loaded) return;
|
||||
const candidates = [
|
||||
path.join(root, 'public', 'regions.geo.json'),
|
||||
path.join(root, 'dist', 'regions.geo.json'),
|
||||
];
|
||||
const file = candidates.find((c) => existsSync(c));
|
||||
if (!file) {
|
||||
console.warn(
|
||||
'[history] regions.geo.json not found (tried: ' + candidates.join(', ') +
|
||||
') — falling back to whole-country (ISO_A3) territory',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const geo = JSON.parse(readFileSync(file, 'utf8')) as {
|
||||
features?: Array<{
|
||||
id?: string | number;
|
||||
properties?: { name?: string; parent?: string };
|
||||
}>;
|
||||
};
|
||||
const index: Record<string, RegionInfo> = {};
|
||||
const byCountry: Record<string, string[]> = {};
|
||||
for (const f of geo.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||
if (!id || !parent) continue;
|
||||
index[id] = { name, parent };
|
||||
(byCountry[parent] ??= []).push(id);
|
||||
}
|
||||
regionIndex = index;
|
||||
regionsByCountry = byCountry;
|
||||
loaded = Object.keys(index).length > 0;
|
||||
} catch (e) {
|
||||
console.warn('[history] failed to load regions.geo.json (' + file + '):', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of ISO_A3 country codes into the union of all their sub-region
|
||||
* ids (deduped, preserving first-seen order). When the region data is not
|
||||
* loaded, returns the input unchanged (graceful fallback to ISO_A3 territory).
|
||||
*/
|
||||
export function expandTerritory(countryCodes: string[]): string[] {
|
||||
if (!loaded) return countryCodes;
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const code of countryCodes) {
|
||||
for (const id of regionsByCountry[code] ?? []) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `{ [regionId]: "name (parent)" }` object for the given region ids, for the
|
||||
* LLM prompt. Unknown ids (not in the loaded index) are skipped. Empty when the
|
||||
* region data is not loaded.
|
||||
*/
|
||||
export function regionNamesFor(ids: Iterable<string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const id of ids) {
|
||||
const info = regionIndex[id];
|
||||
if (info) out[id] = `${info.name} (${info.parent})`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a single region id: `id — name (parent)`, or just
|
||||
* the id when it is unknown (e.g. a region that predates the geo or a
|
||||
* synthetic id).
|
||||
*/
|
||||
export function regionLabel(id: string): string {
|
||||
const info = regionIndex[id];
|
||||
return info ? `${id} — ${info.name} (${info.parent})` : id;
|
||||
}
|
||||
|
||||
/** A bare ISO_A3 country code: exactly three uppercase letters (no hyphen). */
|
||||
const ISO_A3_RE = /^[A-Z]{3}$/;
|
||||
|
||||
/**
|
||||
* Migrate a loaded game's territory from whole-country ISO_A3 codes to
|
||||
* sub-region ids, for nations that still carry ISO_A3 entries (i.e. games
|
||||
* saved before v0.19). Each such nation's ISO_A3 codes are expanded to all
|
||||
* their sub-region ids; any entries that are already region ids (they contain
|
||||
* a hyphen, so they never match `ISO_A3_RE`) are kept. Idempotent and
|
||||
* no-op when the region data is not loaded or a nation has no ISO_A3 codes.
|
||||
* Mutates and returns the (fresh) state it is given.
|
||||
*/
|
||||
export function migrateTerritoryToRegions(state: GameState): GameState {
|
||||
if (!loaded) return state;
|
||||
for (const n of Object.values(state.nations)) {
|
||||
if (!n.territory.some((t) => ISO_A3_RE.test(t))) continue;
|
||||
const isoCodes = n.territory.filter((t) => ISO_A3_RE.test(t));
|
||||
const existingRegions = n.territory.filter((t) => !ISO_A3_RE.test(t));
|
||||
const merged = expandTerritory(isoCodes).concat(existingRegions);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of merged) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
n.territory = out;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- Test-only seams ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test-only: reset the module state to "not loaded" (empty maps,
|
||||
* `hasRegionData()` false). Lets a test exercise the graceful-fallback path.
|
||||
*/
|
||||
export function _resetRegionsForTests(): void {
|
||||
regionIndex = {};
|
||||
regionsByCountry = {};
|
||||
loaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: inject region maps directly (bypassing the file load) so a unit
|
||||
* test can drive `expandTerritory` / `regionNamesFor` / `regionLabel` /
|
||||
* `migrateTerritoryToRegions` against a small, deterministic fixture without
|
||||
* reading the 3.4 MB geo file. `loaded` becomes true iff `index` is non-empty.
|
||||
*/
|
||||
export function _setRegionsForTests(
|
||||
index: Record<string, RegionInfo>,
|
||||
byCountry: Record<string, string[]>,
|
||||
): void {
|
||||
regionIndex = index;
|
||||
regionsByCountry = byCountry;
|
||||
loaded = Object.keys(index).length > 0;
|
||||
}
|
||||
+12
-3
@@ -13,7 +13,8 @@ export interface StubPayload {
|
||||
turn: number;
|
||||
date: string;
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
/** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
|
||||
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
||||
};
|
||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||
pending?: RoundItem[];
|
||||
@@ -30,7 +31,12 @@ function truncate(s: string, n: number): string {
|
||||
* single political "The world moves on" event when the round is empty — so a
|
||||
* caller can predict the exact next state for assertions. It always sets the
|
||||
* player's military to (current + 5) and allies the player with the first other
|
||||
* nation, advancing the date by 2 days.
|
||||
* nation (by key order), advancing the date by 2 days. As of v0.19, when the
|
||||
* round is non-empty it also makes exactly **one** territory move: the player
|
||||
* annexes the lexicographically-first sub-region id of that first other
|
||||
* nation's territory (an empty round makes no move, and a player with nothing
|
||||
* to annex from that nation also makes no move) — so unit tests and e2e can
|
||||
* assert the exact region that changes hands.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -41,6 +47,9 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||
: 'us';
|
||||
const items = p.pending ?? [];
|
||||
// The one deterministic per-turn territory move (see the doc above).
|
||||
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
|
||||
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
|
||||
|
||||
const events: TurnEventSpec[] =
|
||||
items.length === 0
|
||||
@@ -76,7 +85,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges: [],
|
||||
territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+31
-20
@@ -23,7 +23,13 @@ export interface RelationChange {
|
||||
set: RelationStatus;
|
||||
}
|
||||
|
||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
||||
/**
|
||||
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
|
||||
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
|
||||
* border can shift one region at a time; a gained region is moved out of
|
||||
* whichever nation currently owns it, and a lost region with no matching
|
||||
* gainer simply becomes unowned (no-man's-land).
|
||||
*/
|
||||
export interface TerritoryChange {
|
||||
nation: string;
|
||||
gain: string[];
|
||||
@@ -48,26 +54,31 @@ export interface TurnResult {
|
||||
territoryChanges: TerritoryChange[];
|
||||
}
|
||||
|
||||
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
|
||||
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
|
||||
export interface RegionOwner {
|
||||
color: string;
|
||||
owner: string;
|
||||
ownerId: string;
|
||||
}
|
||||
/**
|
||||
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
|
||||
* Every sub-region any nation owns appears here; a sub-region in no nation's
|
||||
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
|
||||
*/
|
||||
export type RegionMap = Record<string, RegionOwner>;
|
||||
|
||||
/**
|
||||
* 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 RegionMap is keyed by the **sub-region's own id** (e.g. `FR-57` — never a
|
||||
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
|
||||
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
|
||||
* key or an unowned region (one in no nation's territory).
|
||||
*
|
||||
* 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.
|
||||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
|
||||
* below, which reads the feature's `properties.regionId` — never the repacked
|
||||
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
|
||||
* #6).
|
||||
*/
|
||||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||
if (!key) return undefined;
|
||||
@@ -76,22 +87,22 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
|
||||
|
||||
/**
|
||||
* 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
|
||||
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
|
||||
* feature's owner is found via its **own sub-region id** — the client stamps
|
||||
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
|
||||
* we look that up with `regionOwner`. This is the single shared source of truth
|
||||
* for that resolution, so the "use `properties.regionId`, never the repacked
|
||||
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
|
||||
* lives in one tested place rather than in the React component.
|
||||
*
|
||||
* Returns `undefined` for a missing feature/parent or an unowned country.
|
||||
* Returns `undefined` for a missing feature/regionId or an unowned region.
|
||||
*/
|
||||
export function mapFeatureOwner(
|
||||
regions: RegionMap,
|
||||
feature: { properties?: { parent?: unknown } } | null | undefined,
|
||||
feature: { properties?: { regionId?: unknown } } | null | undefined,
|
||||
): RegionOwner | undefined {
|
||||
const parent = feature?.properties?.parent;
|
||||
return regionOwner(regions, typeof parent === 'string' ? parent : '');
|
||||
const regionId = feature?.properties?.regionId;
|
||||
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
|
||||
}
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
|
||||
+9
-2
@@ -26,8 +26,15 @@ export const RELATION_STATUSES: RelationStatus[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
||||
* A single nation in the world. `territory` holds **sub-region ids** (e.g.
|
||||
* `FR-57`) — one entry per individually-ownable sub-region, at sub-region
|
||||
* granularity rather than whole-country ISO_A3. The map colors each sub-region
|
||||
* with this nation's stable color (see protocol.nationColor). At scenario start
|
||||
* a nation owns **all** sub-regions of the countries it holds (the server
|
||||
* expands each ISO_A3 to its sub-regions; see server/regions.expandTerritory),
|
||||
* so no region starts unowned. A sub-region that is in **no** nation's list is
|
||||
* unowned (no-man's-land) — the map renders it grey and it can be gained or
|
||||
* lost region-by-region by the LLM.
|
||||
*/
|
||||
export interface Nation {
|
||||
id: string;
|
||||
|
||||
+5
-4
@@ -590,8 +590,10 @@ function GameScreen({
|
||||
// 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.
|
||||
// was clicked, for a small label in the dossier. The label shows only the
|
||||
// region's name — the static parent-country code (which never updates on a
|
||||
// transfer) is no longer displayed, though the info object still carries it.
|
||||
// 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 }) => {
|
||||
@@ -779,8 +781,7 @@ function GameScreen({
|
||||
</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})` : ''}
|
||||
Selected: {selectedRegion.name ?? 'region'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+84
-79
@@ -14,8 +14,10 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
// 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
|
||||
// a stable top-level `id` (ISO 3166-2 style, e.g. `FR-57`, `US-AK`) and
|
||||
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
|
||||
// As of v0.19 the top-level `id` is also the RegionMap key: ownership (and the
|
||||
// LLM) live at sub-region granularity, so a nation owns individual sub-regions.
|
||||
interface GeoFeature {
|
||||
type: 'Feature';
|
||||
id?: string | number;
|
||||
@@ -101,38 +103,42 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* Since v0.19 ownership and the LLM live at **sub-region** granularity: a nation
|
||||
* owns individual sub-region ids, so each sub-region carries the color of the
|
||||
* nation that owns that exact sub-region (a country can now be split between
|
||||
* nations, and a sub-region in no nation's territory renders grey). Each
|
||||
* sub-region therefore carries:
|
||||
*
|
||||
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
|
||||
* - `regionId` – its own sub-region id (e.g. `FR-57`), 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);
|
||||
* (the top-level `id` is repacked into a numeric index, #6) —
|
||||
* and the **RegionMap key** the owner is resolved through;
|
||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`); a static geographic
|
||||
* fact shown in the dossier label, **not** used for ownership;
|
||||
* - `fill` – the owning nation's stable color (from `RegionMap[regionId]`);
|
||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned sub-region);
|
||||
* - `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`.
|
||||
* Crucially, the owner lookup keys the RegionMap by the feature's **own
|
||||
* sub-region id** (as `properties.regionId` at interaction time, via
|
||||
* `mapFeatureOwner` in @shared/protocol) — **never** by the repacked top-level
|
||||
* `id` (a MapLibre numeric index, #6) and **never** by the parent country code.
|
||||
*/
|
||||
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 sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||||
// The sub-region's own id (e.g. `FR-57`) — 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 regionId = f.id == null ? '' : String(f.id);
|
||||
f.properties.regionId = regionId;
|
||||
// The parent country's ISO_A3 (e.g. `FRA`) — a static geographic fact used
|
||||
// for the dossier label only; ownership is resolved by `regionId`.
|
||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
||||
f.properties.parent = parent;
|
||||
const r = parent ? regions[parent] : undefined;
|
||||
const r = regionId ? regions[regionId] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
@@ -151,17 +157,15 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Build a FeatureCollection of just the sub-regions whose **own sub-region id**
|
||||
* is in `regionIds` (cloned). Used by the territory-change flash: since v0.19
|
||||
* a turn changes individual sub-regions (the RegionMap is keyed by sub-region
|
||||
* id), so each changed id lights up exactly its feature — no expansion to a
|
||||
* whole country needed.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
function featuresByRegionIds(geo: FeatureCollection, regionIds: Iterable<string>): FeatureCollection {
|
||||
const set = new Set(regionIds);
|
||||
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
|
||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||
}
|
||||
|
||||
@@ -174,19 +178,20 @@ function statRow(label: string, value?: number): 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.
|
||||
* Compact hover tooltip: the sub-region's name, its **current owner**
|
||||
* (color-chipped), and 1–2 key stats. When the region has no owning nation, a
|
||||
* minimal "No owner" card is shown instead. The static parent-country code
|
||||
* (`properties.parent`) is deliberately NOT printed here — it is a geographic
|
||||
* fact that never updates when ownership is transferred, so only the live
|
||||
* owner chip (resolved from the RegionMap by sub-region id) is shown.
|
||||
*/
|
||||
function hoverHtml(subRegionName: string, parentCode: string, region?: RegionOwner, nation?: Nation): string {
|
||||
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || parentCode || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || '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(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>`;
|
||||
@@ -202,11 +207,12 @@ interface Props {
|
||||
className?: string;
|
||||
/**
|
||||
* 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.
|
||||
* dossier switches to that nation. Since v0.19 the owner is the nation that
|
||||
* owns that very sub-region (resolved from the RegionMap by sub-region id).
|
||||
* The optional `info` carries the clicked sub-region's own 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, info?: { parent?: string; name?: string }) => void;
|
||||
}
|
||||
@@ -215,24 +221,25 @@ interface Props {
|
||||
* 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
|
||||
* lines. Since v0.19 ownership and the LLM live at **sub-region** granularity:
|
||||
* a nation owns individual sub-region ids, so each feature is filled with the
|
||||
* color of the nation owning that exact sub-region (a country can be split
|
||||
* between nations; unowned sub-regions render grey). Rendered as a fixed,
|
||||
* full-viewport background layer (z-0) behind the floating HUD windows: a
|
||||
* dark-navy basemap with subtle terrain, per-region stable fills (the
|
||||
* choropleth is the star), thin sub-region borders, a brighter/thicker amber
|
||||
* border for the player's regions, a hover tooltip (sub-region name + current
|
||||
* owner + key stats), a **click that switches the dossier to the sub-region's
|
||||
* owning nation** (no popup — see #7), a transient flash when a region's owner
|
||||
* changes (lighting up exactly the changed features), and a per-scenario
|
||||
* initial camera.
|
||||
*
|
||||
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||
* RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, via
|
||||
* RegionMap by the feature's **own sub-region id** (`properties.regionId`, 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.
|
||||
* `id` (MapLibre repacks it into a numeric index at interaction time, #6).
|
||||
* Unowned sub-regions (no RegionMap entry for the id) 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.
|
||||
@@ -306,14 +313,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. `codes` are
|
||||
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
|
||||
// up all of that country's sub-regions.
|
||||
// to zero over a few seconds and self-clean. Idempotent per call. `codes`
|
||||
// are sub-region ids (the RegionMap keys that changed owner since the last
|
||||
// update); each lights up exactly its feature.
|
||||
function animateFlash(codes: string[]) {
|
||||
const geo = baseGeoRef.current;
|
||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||
if (!geo || !src || cancelled) return;
|
||||
src.setData(featuresByParents(geo, codes));
|
||||
src.setData(featuresByRegionIds(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.
|
||||
@@ -348,16 +355,15 @@ export default function WorldMap({
|
||||
|
||||
// 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).
|
||||
// and its **owner** is resolved from the authoritative RegionMap via that
|
||||
// same `properties.regionId` through `mapFeatureOwner` — since v0.19 the
|
||||
// RegionMap is keyed by the sub-region's own id. Never the repacked
|
||||
// top-level `id` (a numeric index, #6).
|
||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { name?: string; regionId?: string; parent?: string };
|
||||
const p = f.properties as { name?: string; regionId?: 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;
|
||||
@@ -373,7 +379,7 @@ export default function WorldMap({
|
||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation))
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
@@ -386,10 +392,9 @@ export default function WorldMap({
|
||||
};
|
||||
|
||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||
// 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
|
||||
// owning nation. Since v0.19 the owner is the nation that owns **this**
|
||||
// sub-region, resolved from `properties.regionId` via the RegionMap (never
|
||||
// the repacked top-level `f.id`, #6). A sub-region with no owner leaves the
|
||||
// current dossier target unchanged — never a broken dossier.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
@@ -455,9 +460,9 @@ export default function WorldMap({
|
||||
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.
|
||||
// Since v0.19 each sub-region carries the color of the nation owning
|
||||
// that exact sub-region, so a country taken at region level shows the
|
||||
// split directly in the fill.
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
@@ -537,10 +542,10 @@ export default function WorldMap({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
// Re-color the choropleth whenever ownership changes, and flash any sub-region
|
||||
// whose owner changed since the last update (a transient, self-cleaning pulse).
|
||||
// The diff is over the sub-region-id-keyed RegionMap; `animateFlash` lights up
|
||||
// exactly the features whose own id changed owner.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const geo = baseGeoRef.current;
|
||||
@@ -557,8 +562,8 @@ export default function WorldMap({
|
||||
|
||||
if (wasPrimed && prev) {
|
||||
const changed = new Set<string>();
|
||||
for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||
if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code);
|
||||
for (const regionId of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||
if ((prev[regionId]?.ownerId ?? '') !== (cur[regionId]?.ownerId ?? '')) changed.add(regionId);
|
||||
}
|
||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user