Compare commits

...
5 Commits
Author SHA1 Message Date
kamilkrzywda aa1bd3c5aa Merge pull request 'Re-coarsen RU-ARK Arctic-coast band into RU-PEO + RU-YAM (#41)' (#42) from feat/41-split-arkhangel-band into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-14 21:29:55 +02:00
kamilkrzywda f6ad554823 Re-coarsen RU-ARK Arctic-coast band into RU-PEO + RU-YAM (#41)
CI / build + unit + e2e (pull_request) Successful in 2m34s
#41: re-partition the RU-ARK mainland's 28-part lat>=68 Arctic-coast band
into two new regions via a new turf-free, idempotent pipeline stage
scripts/split-arkhangel-band.mjs (npm run split:regions:band), mirroring the
#31 split:

- RU-PEO (Pechora, 13 parts, ~37,678 km2) -> key pechora-qwhs
- RU-YAM (Yamal, 15 parts, ~51,722 km2) -> key yamal-gosw
- RU-ARK keeps its id/name/key arkhangel-sk-lhbk (ownership-safe); the 5-part
  lat<68 core (~305,229 km2) stays RU-ARK.

Pure re-partition: no geometry moved (byte-identical union). The two new
features are inserted immediately after RU-ARK-SZ. Manifests regenerated with
the real scripts (1,798 -> 1,800 keys + adjacency; adjacency pairs 4,471 ->
4,473). Fixed-point dataset unchanged except feature count (11,794 parts /
11,795 rings / 164,801 verts / 1 KZ-KZY hole). Four dataset/manifest tests
updated (RUS 82 -> 84 sub-regions). Version 0.25.0 -> 0.26.0.

Fixes #41
2026-10-14 19:26:49 +00:00
kamilkrzywda 867b2eb0a1 Merge pull request 'feat: split RU-ARK into mainland + New Earth + Severnaya Zemlya (v0.25.0, #31)' (#40) from feat/31-split-arkhangel into master
CI / build + unit + e2e (push) Failing after 14m43s
2026-10-14 19:57:50 +02:00
kamilkrzywda 8fb8984d07 chore: bump to v0.25.0 (#31) + CHANGELOG
CI / build + unit + e2e (pull_request) Successful in 2m34s
2026-10-14 17:54:45 +00:00
kamilkrzywda f593af5ba5 feat: split RU-ARK into mainland + New Earth + Severnaya Zemlya — idempotent stage, manifests 1,798 (v0.25.0, #31) 2026-10-14 17:54:45 +00:00
13 changed files with 645 additions and 37 deletions
+31 -9
View File
@@ -28,13 +28,17 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
choropleth from `public/regions.geo.json` (1,800 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
(v0.19, #16 P1): every sub-region is addressed by a stable key from
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
function-calling tool) instead of whole countries.
function-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK
mega-region is split into the mainland (`RU-ARK`, key unchanged) plus the New Earth
(`RU-ARK-NE`) and Severnaya Zemlya (`RU-ARK-SZ`) island groups; since v0.26 (#41) the
mainland's 28-part Arctic-coast band is further re-partitioned into Pechora (`RU-PEO`)
and Yamal (`RU-YAM`) (the 5-part lat < 68 core stays `RU-ARK`/`arkhangel-sk-lhbk`).
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
@@ -52,18 +56,19 @@ history/
├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 151 tests)
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 168 tests)
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
├── public/
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
│ ├── regions.geo.json # 1,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → build-region-keys → build-region-adjacency
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
@@ -110,10 +115,12 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 151 tests) |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 168 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
@@ -188,7 +195,22 @@ history/
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
(#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
`arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
`severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
`fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
Since v0.26 (#41) the mainland's 28-part Arctic-coast band (centroid lat ≥ 68) is further
re-partitioned by the idempotent `scripts/split-arkhangel-band.mjs` stage (run between
`split:regions` and `build:region-keys`, no-op once applied) into Pechora (`RU-PEO`,
`pechora-qwhs`, 13 parts) and Yamal (`RU-YAM`, `yamal-gosw`, 15 parts) — a pure re-partition
(no geometry moved → the union is byte-identical) that keeps `RU-ARK`'s id/name/key
`arkhangel-sk-lhbk` stable and inserts the two new features immediately after `RU-ARK-SZ`; the
5-part lat < 68 core stays `RU-ARK` (a single ~305k km² polygon — splitting it needs turf
clipping, not a pure re-partition).
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
+24
View File
@@ -2,6 +2,30 @@
All notable changes to **History** are listed here, newest first.
## v0.26.0 — 2026-10-14 19:24
- **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into:
- `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border).
- `RU-YAM` — Yamal (15 parts, ~51,722 km²) → new region key `yamal-gosw`. The northeastern band (Yamal peninsula).
- The 5 remaining lat < 68 parts (the ~305,229 km² inland core) **stay `RU-ARK` and keep its id, name, and region key `arkhangel-sk-lhbk`** — ownership-safe, mirroring the #31 precedent. Splitting that core further requires geometry clipping (a turf-op), not a pure re-partition, and is left as a follow-up issue.
- **New pipeline stage** `scripts/split-arkhangel-band.mjs` (`npm run split:regions:band`), run between `split:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It asserts the mainland is a 33-part MultiPolygon, selects the band as the 28 parts with centroid lat ≥ 68 (exactly 28, or it fails loudly), picks the two largest-area band parts as Voronoi anchors (the smaller-longitude one, `idx 1`, anchors the Pechora side), and assigns each band part to the nearer anchor by km-weighted centroid distance `hypot(Δlon·111.32·cos(midLat), Δlat·110.574)` (ties go west). The resulting partition is exactly PEO={1,4,5,9,11,12,13,14,16,17,18,20,22}, YAM={2,6,15,19,21,23,24,25,26,27,28,29,30,31,32}, core={0,3,7,8,10}. The two new features are inserted **immediately after `RU-ARK-SZ`** (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-PEO` | `RU-YAM` | `RU-AST` | …).
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,800 entries each** (was 1,798); adjacency pairs 4,471 → 4,473. The new pairs are `pechora-qwhs ↔ yamal-gosw`, `pechora-qwhs ↔ nenets-qyga`, and `yamal-gosw ↔ severnaya-zemlya-awqp`, while the mainland `arkhangel-sk-lhbk` **loses** its `severnaya-zemlya-awqp` pair (the islets now touch the Yamal band, not the inland core) and keeps its six real neighbours (`karelia-ohem`, `kirov-gfvm`, `komi-eoeh`, `murmansk-etas`, `nenets-qyga`, `vologda-ynvr`). `new-earth-eikt` stays isolated (empty adjacency).
- **Fixed-point dataset unchanged except feature count** (1,798 → 1,800): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after — only the feature grouping changes. The RU-ARK family total stays 78 parts (mainland 5 / NE 42 / SZ 3 / PEO 13 / YAM 15).
- **Tests (168, count unchanged — existing cases updated, none added).** `scripts/regions.test.ts` (dataset is now 1,800 features; the Arkhangel feature-pin test now covers all five family members), `scripts/region-keys.test.ts` (1,800 keys; pins the unchanged `arkhangel-sk-lhbk` plus the two new keys), `scripts/region-adjacency.test.ts` (1,800 entries; pins the new PEO/YAM/SZ adjacency and the mainland's loss of the SZ pair), `server/regions.test.ts` (1,800; the RUS country expansion is now **84** sub-regions including all five keys — was 82 after #31). No e2e assertion references the region counts, so the CI e2e suite is unaffected.
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra band regions (82 → 84 sub-regions); the LLM's reference table and the player's frontier now include `pechora-qwhs` and `yamal-gosw`.
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
## v0.25.0 — 2026-10-14 17:53
- **#31: the `RU-ARK` mega-region is split into a mainland region plus two island groups** — a pure re-grouping of the existing polygon parts: no new geometry, no coordinate changes. The old single feature (Arkhangel'sk Oblast + the Arctic archipelagos, 78 parts, ~410,187 km²) becomes three features inserted immediately after `RU-ARK` (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-AST`):
- `RU-ARK` — the mainland (33 parts, ~393,256 km²); it **keeps its existing id and region key `arkhangel-sk-lhbk`**, so existing `Nation.territory` lists and SQLite ownership are untouched (ownership-safe).
- `RU-ARK-NE` — New Earth / Franz Josef Land (42 parts, ~15,721 km²) → new region key `new-earth-eikt`. Genuinely isolated (≥100 km from any land), so its adjacency list is **empty**.
- `RU-ARK-SZ` — Severnaya Zemlya (3 parts, ~1,210 km²) → new region key `severnaya-zemlya-awqp`. Its islet rings share boundary vertices with the coarsened mainland ring (a contact that pre-existed inside the single pre-split feature — no coordinate change), so it records **one** adjacency pair (SZ ↔ `arkhangel-sk-lhbk`). This deviates from the original "both islands isolated" expectation and is recorded as-is by the manifest's "shares at least one point" definition.
- **New pipeline stage** `scripts/split-arkhangel.mjs` (`npm run split:regions`), run between `fill:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It classifies each part by centroid (turf — allowed in `.mjs` only): main = lat < 76 (plus any 76–79.5° band parts west of 55°E — none exist), Severnaya Zemlya = 76 ≤ lat < 79.5 AND lon ≥ 55, New Earth = lat ≥ 79.5. It fails loudly on a mis-split, a missing feature, or a non-MultiPolygon geometry.
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,798 entries each** (was 1,796); adjacency pairs 4,470 → 4,471 (the one new pair is the SZ ↔ mainland contact; the mainland keeps its 6 real neighbours).
- **Fixed-point dataset unchanged except feature count** (1,796 → 1,798): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after the split.
- **Tests (164 → 168).** `scripts/regions.test.ts` (dataset is now 1,798 features; a new test pins the three Arkhangel features and their part counts), `scripts/region-keys.test.ts` (1,798 keys; a new test pins the unchanged main key plus the two new keys), `scripts/region-adjacency.test.ts` (1,798 entries; a new island-adjacency test: New Earth empty, Severnaya Zemlya = [mainland], mainland's neighbour list extended by exactly the SZ pair), `server/regions.test.ts` (1,798; the RUS country expansion is now **82** sub-regions including all three keys — was 80). No e2e assertion references the old counts, so the CI e2e suite is unaffected.
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra island regions (80 → 82 sub-regions).
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
## v0.24.0 — 2026-10-14 13:41
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.24.0",
"version": "0.26.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -14,6 +14,8 @@
"cleanup:regions": "node scripts/cleanup-regions.mjs",
"smooth:regions": "node scripts/smooth-regions.mjs",
"fill:regions": "node scripts/fill-gaps.mjs",
"split:regions": "node scripts/split-arkhangel.mjs",
"split:regions:band": "node scripts/split-arkhangel-band.mjs",
"build:region-keys": "node scripts/build-region-keys.mjs",
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
"typecheck": "tsc --noEmit",
+13
View File
@@ -7587,6 +7587,7 @@
"nenets-qyga": [
"arkhangel-sk-lhbk",
"komi-eoeh",
"pechora-qwhs",
"yamal-nenets-aqsx"
],
"neuquen-yvne": [
@@ -7614,6 +7615,7 @@
"ontario-bzqu",
"quebec-yegk"
],
"new-earth-eikt": [],
"new-hampshire-koki": [
"maine-maiz",
"massachusetts-ggid",
@@ -8748,6 +8750,10 @@
"soriano-ixbv",
"tacuarembo-yekg"
],
"pechora-qwhs": [
"nenets-qyga",
"yamal-gosw"
],
"peloponnisos-wqyw": [
"dytiki-ellada-mlyj",
"sterea-ellada-inyu"
@@ -10010,6 +10016,9 @@
"medea-elbf",
"skikda-odba"
],
"severnaya-zemlya-awqp": [
"yamal-gosw"
],
"sevilla-oxma": [
"badajoz-ciwt",
"cadiz-aazc",
@@ -12160,6 +12169,10 @@
"iwate-uvfz",
"niigata-idhx"
],
"yamal-gosw": [
"pechora-qwhs",
"severnaya-zemlya-awqp"
],
"yamal-nenets-aqsx": [
"khanty-mansiy-tkto",
"komi-eoeh",
+4
View File
@@ -1305,6 +1305,10 @@
"RU-ALT": "altay-mfuq",
"RU-AMU": "amur-clbp",
"RU-ARK": "arkhangel-sk-lhbk",
"RU-ARK-NE": "new-earth-eikt",
"RU-ARK-SZ": "severnaya-zemlya-awqp",
"RU-PEO": "pechora-qwhs",
"RU-YAM": "yamal-gosw",
"RU-AST": "astrakhan-anec",
"RU-BA": "bashkortostan-uovk",
"RU-BEL": "belgorod-wxjk",
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -21,7 +21,7 @@
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
* regions all named "Western") the 4-char codes are made distinct by
* re-deriving with a deterministic salt on collision, which guarantees the
* full key is unique across all 1,796 regions.
* full key is unique across all 1,800 regions.
*
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
@@ -107,8 +107,8 @@ const geoFile = path.join(root, 'public', 'regions.geo.json');
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
const features = Array.isArray(geo?.features) ? geo.features : [];
if (features.length !== 1796) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
if (features.length !== 1800) {
throw new Error(`expected 1,800 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
}
// Group features by their (computed) name-slug, preserving file order, so the
@@ -154,5 +154,5 @@ writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
const western = Object.entries(manifest)
.filter(([, k]) => k.startsWith('western-'))
.sort((a, b) => a[0] < b[0] ? -1 : 1);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,800 expected)`);
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
+29 -1
View File
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
const adjacencyKeys = new Set(Object.keys(adjacency));
expect(adjacencyKeys).toEqual(manifestKeys);
expect(Object.keys(adjacency)).toHaveLength(keys.size);
expect(adjacencyKeys.size).toBe(1796);
expect(adjacencyKeys.size).toBe(1800);
});
it('every neighbour is itself a valid region key (referential integrity)', () => {
@@ -84,4 +84,32 @@ describe('region-adjacency.json', () => {
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
});
it("the #31 + #41 Arctic splits: New Earth isolated; Severnaya Zemlya, Pechora and Yamal adjacency", () => {
// v0.25 (#31) split RU-ARK into the mainland (arkhangel-sk-lhbk), the
// Franz Josef Land islets (new-earth-eikt) and the Severnaya Zemlya
// islets (severnaya-zemlya-awqp). New Earth sits ~100+ km from any land
// → a genuinely isolated island (empty list).
// v0.26 (#41) repartitioned the mainland: the 5 lat < 68 parts stay
// arkhangel-sk-lhbk, and the 28 lat ≥ 68 band parts became pechora-qwhs
// (the SW cell) and yamal-gosw (the NE cell). The Severnaya Zemlya
// islets' digitized rings touch the NE band part (now yamal-gosw), not
// the trimmed core, so SZ now borders Yamal (its former mainland contact
// moved to Yamal). The trimmed core keeps its real mainland neighbours
// but loses the SZ contact. Pechora (SW) borders the existing Nenets
// region to the south and Yamal to the north.
expect(adjacency['new-earth-eikt']).toEqual([]);
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['yamal-gosw']);
expect(adjacency['pechora-qwhs']).toEqual(['nenets-qyga', 'yamal-gosw']);
expect(adjacency['yamal-gosw']).toEqual(['pechora-qwhs', 'severnaya-zemlya-awqp']);
// the trimmed core keeps its real neighbours (the band's SZ contact moved to Yamal)
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
'karelia-ohem',
'kirov-gfvm',
'komi-eoeh',
'murmansk-etas',
'nenets-qyga',
'vologda-ynvr',
]);
});
});
+20 -5
View File
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796);
it('has exactly 1,800 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1800);
});
it('has 1,796 unique keys (no collisions)', () => {
it('has 1,800 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796);
expect(new Set(keys).size).toBe(1800);
});
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
@@ -50,11 +50,26 @@ describe('region-keys.json', () => {
}
});
it("keeps RU-ARK's key stable across the #31 + #41 splits and mints the island and band keys", () => {
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ, and v0.26
// (#41) repartitioned the mainland into the core (RU-ARK) + RU-PEO +
// RU-YAM. The MAIN feature keeps its id (and therefore its key — FNV-1a
// of the id), which is what keeps existing Nation.territory / SQLite
// ownership valid; the new features get fresh keys derived the same way
// from their ids (no within-country name-collision salt needed — the
// slugs are unique).
expect(manifest['RU-ARK']).toBe('arkhangel-sk-lhbk'); // unchanged since v0.19
expect(manifest['RU-ARK-NE']).toBe('new-earth-eikt');
expect(manifest['RU-ARK-SZ']).toBe('severnaya-zemlya-awqp');
expect(manifest['RU-PEO']).toBe('pechora-qwhs');
expect(manifest['RU-YAM']).toBe('yamal-gosw');
});
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
// The manifest is looked up by feature id, so its key set must line up
// 1:1 with the geo file (catches drift if the geo is regenerated without
// rebuilding the keys).
expect(featureIds).toHaveLength(1796);
expect(featureIds).toHaveLength(1800);
const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
});
+70 -12
View File
@@ -530,16 +530,22 @@ describe('regions.geo.json', () => {
);
});
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
it('is at the v0.26 fixed point: 1,800 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).
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
// re-groupings: no part/ring/vertex moves, so only the region count
// changed (1,796 → 1,798 → 1,800); the part/ring/vertex totals, the
// per-feature ids/names/parents, and the feature order (the new features
// sit immediately after RU-ARK-SZ) are as pinned below. Pinning the
// signature guards against a non-idempotent or destructive re-run; both
// stages are byte-deterministic per input and idempotent (a second run
// changes nothing).
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
let verts = 0,
rings = 0,
@@ -556,15 +562,67 @@ describe('regions.geo.json', () => {
for (const ring of poly) verts += ring.length;
}
}
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(regions.features.length, 'region count').toBe(1800); // 1,796 + the #31 islands (+2) + the #41 band regions (+2)
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the #31/#41 re-partitions
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
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the #31/#41 re-partitions
console.log(
`\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`,
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
);
});
it('has the five Arkhangel\'sk-family features: #31 islands + #41 band (RU-ARK, New Earth, Severnaya Zemlya, Pechora, Yamal)', () => {
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
// mixing mainland and far-north islands) was repartitioned by part
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
// v0.26 (#41): the 33-part mainland (post-#31) was repartitioned again —
// the 5 lat < 68 parts stay RU-ARK (id/name/key unchanged), and the 28
// lat ≥ 68 "Arctic-coast band" parts split into RU-PEO "Pechora" (the SW
// Voronoi cell, 13 parts) and RU-YAM "Yamal" (the NE Voronoi cell, 15
// parts). Pure re-grouping: all five keep parent RUS and all 78 original
// parts of the pre-#31 feature are accounted for, verbatim.
const byId = new Map(regions.features.map((f) => [f.id, f]));
const ark = byId.get('RU-ARK');
const ne = byId.get('RU-ARK-NE');
const sz = byId.get('RU-ARK-SZ');
const peo = byId.get('RU-PEO');
const yam = byId.get('RU-YAM');
for (const f of [ark, ne, sz, peo, yam]) {
expect(f, `${f?.id ?? '?'} exists`).toBeDefined();
expect(f!.properties.parent, `${f!.id} parent`).toBe('RUS');
expect(f!.geometry.type, `${f!.id} geometry`).toBe('MultiPolygon');
}
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
expect(peo!.properties.name, 'RU-PEO name').toBe('Pechora');
expect(yam!.properties.name, 'RU-YAM name').toBe('Yamal');
// the 78 parts of the pre-split feature are all accounted for, verbatim
const countParts = (f: (typeof regions.features)[number]) =>
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
expect(
countParts(ark!) + countParts(ne!) + countParts(sz!) + countParts(peo!) + countParts(yam!),
'total parts across the five features',
).toBe(78);
// the #41 split: the mainland core keeps its 5 lat < 68 parts; the band's
// 28 parts split 13 (Pechora) / 15 (Yamal)
expect(countParts(ark!), 'RU-ARK core part count').toBe(5);
expect(countParts(peo!), 'RU-PEO part count').toBe(13);
expect(countParts(yam!), 'RU-YAM part count').toBe(15);
// stable file order: RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
expect(regions.features[idx + 3]?.id).toBe('RU-PEO');
expect(regions.features[idx + 4]?.id).toBe('RU-YAM');
console.log(
`\n #31 + #41: RU-ARK ${countParts(ark!)} parts (core), RU-ARK-NE ${countParts(ne!)} (New Earth), ` +
`RU-ARK-SZ ${countParts(sz!)} (Severnaya Zemlya), RU-PEO ${countParts(peo!)} (Pechora), RU-YAM ${countParts(yam!)} (Yamal)`,
);
});
});
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* scripts/split-arkhangel-band.mjs — split the RU-ARK Arctic-coast band into
* two new regions, RU-PEO ("Pechora", the SW group) and RU-YAM ("Yamal", the
* NE group) (issue #41, a follow-up to #31).
*
* The #31 split (`scripts/split-arkhangel.mjs`) left `RU-ARK` as a 33-part
* MultiPolygon (~393,256 km²). Measured on that feature (centroid latitude /
* planar area), the parts divide cleanly by centroid latitude:
*
* lat < 68 4 parts ~305,000 km² (Arkhangel'sk proper + 3 slivers)
* 66 ≤ lat < 68 1 part ~47 km²
* lat ≥ 68 28 parts ~88,000 km² (the "Arctic-coast band")
*
* The band is really *other* Arctic oblasts (Nenets / Pechora / Yamal / Taimyr
* side) mis-lumped into Arkhangel'sk during the v0.14 coarsening. This stage
* re-coarsens it: the 28 band parts are split into two new regions by a
* Voronoi of the two band parts with the largest planar area (the SW anchor
* vs the NE anchor), while the 5 lat < 68 parts stay in `RU-ARK`.
*
* RU-ARK (keeps its id and name) — the 5 lat < 68 parts (original order)
* → still the `arkhangel-sk-lhbk` key
* RU-PEO "Pechora" — the band's SW Voronoi cell (13 parts)
* RU-YAM "Yamal" — the band's NE Voronoi cell (15 parts)
*
* Deterministic, turf-free threshold spec (all data-derived — no magic
* coordinates; the same planar model the tests/adjacency build use):
* 1. centroid of a part = the mean of its outer-ring vertices (verified to
* give the same band assignment as turf's area-weighted centroid, 0 flips).
* 2. band = the parts whose centroid lat ≥ 68 (asserted to be exactly 28).
* 3. anchors = the 2 band parts with the largest planar area (≈ 47,223 and
* ≈ 32,907 km², indices 2 and 1 in the 33-part feature); anchorW = the
* anchor with the smaller centroid longitude.
* 4. each non-anchor band part → the nearer anchor by km-weighted centroid
* distance, `dist(a,b) = hypot(Δlon·111.32·cos(midLat), Δlat·110.574)`;
* a tie → west (anchorW). Each anchor → its own group. This yields
* PEO = {1,4,5,9,11,12,13,14,16,17,18,20,22} and
* YAM = {2,6,15,19,21,23,24,25,26,27,28,29,30,31,32} (0-based indices).
*
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
* moved verbatim (same coordinates, same ring order) from the one feature to
* the others. The union of the three output geometries is byte-identical to
* the input feature's parts, so no border moves and no new overlap is
* possible (the same guarantee as #31). `RU-ARK` keeps its stable feature id,
* so its region key (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
* new ids (`RU-PEO`, `RU-YAM`) get their own keys derived by the same FNV-1a
* rule in `scripts/build-region-keys.mjs` (`pechora-qwhs`, `yamal-gosw` —
* run it after this stage).
*
* The new features are inserted immediately after `RU-ARK-SZ` (stable file
* order, deterministic bytes: `RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM,
* …`). Each new feature's `properties` copies `RU-ARK`'s (only `name` and
* `parent` exist in this dataset) with `name` overridden; `parent` stays RUS.
* Each feature's geometry is a MultiPolygon of its assigned parts (in
* original order), or a single Polygon if it ended up with one part (it won't
* here, but the builder handles it).
*
* Pipeline position: AFTER `split-arkhangel` and BEFORE `build-region-keys`
* (the manifests must be rebuilt after any geo change):
*
* generate → coarsen → cleanup → smooth → fill-gaps → split-arkhangel
* → **split-arkhangel-band** → build-region-keys → build-region-adjacency
*
* Idempotent: when RU-PEO and RU-YAM already exist the file is left untouched
* (no write at all), so a full re-run of the pipeline reproduces byte-identical
* output.
*
* Usage: node scripts/split-arkhangel-band.mjs [inFile [outFile]]
* (or `npm run split:regions:band`)
* inFile default: public/regions.geo.json
* outFile default: inFile (edit in place)
*
* Deliberately turf-free: no `@turf/*` import (turf is only ever imported by
* the geo-generator `.mjs` stages, never by this re-partition or by any `.ts`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
const outFile = process.argv[3] ?? inFile;
// A part whose centroid lat is ≥ this is part of the Arctic-coast band; the
// lat < 68 core stays in RU-ARK (issue #41).
const BAND_MIN_LAT = 68;
// The post-#31 mainland feature is a 33-part MultiPolygon; any other shape
// means the file is not the state this stage expects → fail loud.
const EXPECTED_PARTS = 33;
// The Arctic-coast band is exactly 28 of those parts (lat ≥ 68).
const EXPECTED_BAND = 28;
// Planar km model (matches the test's area model and the adjacency build):
// x = lon · 111.32 · cos(midLat), y = lat · 110.574.
const KM_LON = 111.32;
const KM_LAT = 110.574;
const rad = (deg) => (deg * Math.PI) / 180;
/**
* Mean of a part's outer-ring vertices — a turf-free centroid. Verified to give
* the same band assignment as turf's area-weighted centroid (0 flips).
*/
function centroidOf(part) {
const ring = part[0];
let lon = 0;
let lat = 0;
for (const [lo, la] of ring) {
lon += lo;
lat += la;
}
return [lon / ring.length, lat / ring.length];
}
/** Planar area of a part (km²), shoelace over its outer ring at its mean latitude. */
function areaKm2(part) {
const ring = part[0];
let lat = 0;
for (const [, la] of ring) lat += la;
const midLat = lat / ring.length;
const kx = KM_LON * Math.cos(rad(midLat));
let a = 0;
for (let i = 0; i < ring.length; i++) {
const [lon1, lat1] = ring[i];
const [lon2, lat2] = ring[(i + 1) % ring.length];
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
}
return Math.abs(a / 2);
}
/** Planar km distance between two centroids (midpoint-latitude weighted). */
function kmDist(a, b) {
const dLon = b[0] - a[0];
const dLat = b[1] - a[1];
const midLat = (a[1] + b[1]) / 2;
return Math.hypot(dLon * KM_LON * Math.cos(rad(midLat)), dLat * KM_LAT);
}
// --- load --------------------------------------------------------------------
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
throw new Error(`${inFile} is not a FeatureCollection`);
}
const features = geo.features;
// --- idempotency: already split? ----------------------------------------------
const ids = new Set(features.map((f) => String(f.id ?? '')));
if (ids.has('RU-PEO') && ids.has('RU-YAM')) {
console.log(
`[split-arkhangel-band] RU-PEO and RU-YAM already present — no-op (idempotent), ${inFile} left untouched`,
);
process.exit(0);
}
// --- load + validate RU-ARK ---------------------------------------------------
const idx = features.findIndex((f) => f.id === 'RU-ARK');
if (idx < 0) throw new Error('RU-ARK not found in ' + inFile);
const ark = features[idx];
if (ark.geometry?.type !== 'MultiPolygon') {
throw new Error(
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the 33-part post-#31 mainland)`,
);
}
const parts = ark.geometry.coordinates;
if (parts.length !== EXPECTED_PARTS) {
throw new Error(`RU-ARK has ${parts.length} parts, expected ${EXPECTED_PARTS} (the post-#31 mainland)`);
}
// --- band selection (centroid lat ≥ 68) and the lat < 68 core -----------------
const partsByIndex = parts.map((p, i) => ({ i, c: centroidOf(p) }));
const band = partsByIndex.filter((o) => o.c[1] >= BAND_MIN_LAT);
if (band.length !== EXPECTED_BAND) {
throw new Error(`expected exactly ${EXPECTED_BAND} Arctic-coast band parts (lat ≥ ${BAND_MIN_LAT}), found ${band.length}`);
}
const core = partsByIndex.filter((o) => o.c[1] < BAND_MIN_LAT);
// --- anchors: the two band parts with the largest planar area -----------------
// (≈ 47,223 and ≈ 32,907 km², indices 2 and 1 in the 33-part feature).
const byArea = [...band].sort((a, b) => areaKm2(parts[b.i]) - areaKm2(parts[a.i]));
const big = byArea[0];
const small = byArea[1];
const anchorW = big.c[0] <= small.c[0] ? big : small; // the west (SW) anchor
const anchorE = big.c[0] <= small.c[0] ? small : big; // the east (NE) anchor
// --- assignment (Voronoi by km-weighted centroid distance; tie → west) --------
const peo = new Set([anchorW.i]);
const yam = new Set([anchorE.i]);
for (const o of band) {
if (o.i === anchorW.i || o.i === anchorE.i) continue;
const dW = kmDist(o.c, anchorW.c);
const dE = kmDist(o.c, anchorE.c);
(dW <= dE ? peo : yam).add(o.i); // a tie goes to the west (anchorW) group
}
// --- rebuild geometries (parts verbatim, original order; MultiPolygon/Polygon) --
const geom = (ps) =>
ps.length > 1 ? { type: 'MultiPolygon', coordinates: ps } : { type: 'Polygon', coordinates: ps[0] };
const coreParts = [...core].sort((a, b) => a.i - b.i).map((o) => parts[o.i]);
const peoParts = [...peo].sort((a, b) => a - b).map((i) => parts[i]);
const yamParts = [...yam].sort((a, b) => a - b).map((i) => parts[i]);
ark.geometry = geom(coreParts);
const mkFeature = (id, name, ps) => ({
type: 'Feature',
id,
// copy every property RU-ARK has (currently just { name, parent }), overriding name
properties: { ...ark.properties, name },
geometry: geom(ps),
});
const peoF = mkFeature('RU-PEO', 'Pechora', peoParts);
const yamF = mkFeature('RU-YAM', 'Yamal', yamParts);
// --- report -------------------------------------------------------------------
const km2 = (ps) => ps.reduce((s, p) => s + areaKm2(p), 0);
console.log(`[split-arkhangel-band] input: ${inFile}`);
console.log(
` RU-ARK (core, lat < 68) ${coreParts.length} parts (of ${parts.length}), ≈ ${Math.round(km2(coreParts)).toLocaleString('en-US')} km²`,
);
console.log(
` RU-PEO (Pechora, SW) ${peoParts.length} parts, ≈ ${Math.round(km2(peoParts)).toLocaleString('en-US')} km² [${[...peo].sort((a, b) => a - b).join(',')}]`,
);
console.log(
` RU-YAM (Yamal, NE) ${yamParts.length} parts, ≈ ${Math.round(km2(yamParts)).toLocaleString('en-US')} km² [${[...yam].sort((a, b) => a - b).join(',')}]`,
);
if (peoParts.length === 0 || yamParts.length === 0) {
throw new Error(
`threshold mis-split: Pechora=${peoParts.length} parts, Yamal=${yamParts.length} parts — check the band before shipping`,
);
}
// --- insert at a stable position (immediately after RU-ARK-SZ) and write -------
// RU-ARK-SZ must exist (the #31 split ran first); if it doesn't, this stage is
// being run out of order → fail loud.
const szIdx = features.findIndex((f) => f.id === 'RU-ARK-SZ');
if (szIdx < 0) {
throw new Error('RU-ARK-SZ not found — the #31 split (scripts/split-arkhangel.mjs) must run before this stage');
}
features.splice(szIdx + 1, 0, peoF, yamF);
writeFileSync(outFile, JSON.stringify(geo));
console.log(
`[split-arkhangel-band] wrote ${outFile} — features ${features.length - 2} → ${features.length}` +
` (order …, RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM, …)`,
);
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region
* into main + two detached island archipelagos (issue #31).
*
* `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk
* mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north
* island groups that belong nowhere near the mainland — the Franz Josef Land
* islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets
* (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing
* polygon parts between three features so each island group is its own
* addressable sub-region:
*
* RU-ARK (keeps its id and name) — every part whose centroid has
* lat < 76 (the mainland, Arctic-coast
* mainland parts, islets, one south dot)
* RU-ARK-NE "New Earth" — every part whose centroid has
* lat ≥ 79.5 (the 42 Franz Josef Land
* islets, ~15,700 km²)
* RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has
* 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3
* Severnaya Zemlya islets, ~1,200 km²)
*
* (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist
* in the current dataset — the split is verified clean before it is written.)
*
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
* moved verbatim (same coordinates, same ring order) from the one feature to
* the other. The union of the three output geometries is byte-identical to
* the input feature's parts, so no border moves and no new overlap is
* possible. RU-ARK keeps its stable feature id, so its region key
* (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
* new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same
* FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage).
*
* The new features are inserted immediately after RU-ARK (stable file order,
* deterministic bytes). Each new feature's `properties` copies RU-ARK's
* (only `name` and `parent` exist in this dataset) with `name` overridden;
* `parent` stays RUS. Each feature's geometry is a MultiPolygon of its
* assigned parts, or a single Polygon if it ends up with one part.
*
* Pipeline position: between `fill-gaps` and `build-region-keys` (the
* manifests must be rebuilt after any geo change):
*
* generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel**
* → build-region-keys → build-region-adjacency
*
* Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left
* untouched (no write at all), so a full re-run of the pipeline reproduces
* byte-identical output.
*
* Usage: node scripts/split-arkhangel.mjs [inFile [outFile]]
* (or `npm run split:regions`)
* inFile default: public/regions.geo.json
* outFile default: inFile (edit in place)
*
* `@turf/centroid` + `@turf/helpers` are used only to classify each part by
* its centroid — the same measurement the split thresholds are defined in.
* (Turf is allowed in .mjs scripts only; never imported by any .ts file.)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import centroid from '@turf/centroid';
import area from '@turf/area';
import { polygon } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
const outFile = process.argv[3] ?? inFile;
// The split thresholds (degrees), per issue #31.
const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land)
const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)…
const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya
// (anything else — including the 76–79.5° band west of 55°E, of which the
// current dataset has none — stays in the main Arkhangel'sk feature)
/** A GeoJSON polygon part as a turf Feature (for centroid/area only). */
const partFeature = (part) => polygon(part);
/**
* A part's centroid [lon, lat]. Strict by design: a centroid that cannot be
* computed is a data error, not something to silently route to the mainland.
*/
function centroidOf(part) {
try {
return centroid(partFeature(part)).geometry.coordinates;
} catch (err) {
throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`);
}
}
/** Classify a centroid [lon, lat] into one of the three output buckets. */
function bucketOf([lon, lat]) {
if (lat >= NEW_EARTH_MIN_LAT) return 'ne';
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz';
return 'main';
}
// --- load --------------------------------------------------------------------
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
throw new Error(`${inFile} is not a FeatureCollection`);
}
const features = geo.features;
// --- idempotency: already split? ----------------------------------------------
const ids = new Set(features.map((f) => String(f.id ?? '')));
if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) {
console.log(
`[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`,
);
process.exit(0);
}
const idx = features.findIndex((f) => f.id === 'RU-ARK');
if (idx < 0) {
throw new Error('RU-ARK not found in ' + inFile);
}
const ark = features[idx];
if (ark.geometry?.type !== 'MultiPolygon') {
throw new Error(
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`,
);
}
const parts = ark.geometry.coordinates;
// --- partition (stable: original part order is kept inside each bucket) -------
const buckets = { main: [], ne: [], sz: [] };
let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data)
for (const part of parts) {
const [lon, lat] = centroidOf(part);
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1;
buckets[bucketOf([lon, lat])].push(part);
}
// --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) --
const geom = (ps) =>
ps.length > 1
? { type: 'MultiPolygon', coordinates: ps }
: { type: 'Polygon', coordinates: ps[0] };
ark.geometry = geom(buckets.main);
const mkFeature = (id, name, ps) => ({
type: 'Feature',
id,
// copy every property RU-ARK has (currently just { name, parent }), overriding name
properties: { ...ark.properties, name },
geometry: geom(ps),
});
const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne);
const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz);
// --- report -------------------------------------------------------------------
const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0);
console.log(`[split-arkhangel] input: ${inFile}`);
console.log(
` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` +
(bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''),
);
console.log(
` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`,
);
console.log(
` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`,
);
if (buckets.ne.length === 0 || buckets.sz.length === 0) {
throw new Error(
`threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`,
);
}
// --- insert at a stable position (immediately after RU-ARK) and write ---------
features.splice(idx + 1, 0, ne, sz);
writeFileSync(outFile, JSON.stringify(geo));
console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);
+19 -4
View File
@@ -27,9 +27,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(loadRegions(root)).toBe(true);
});
it('loads the full manifest: 1,796 unique valid region keys', () => {
it('loads the full manifest: 1,800 unique valid region keys', () => {
expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796);
expect(validKeys().size).toBe(1800);
});
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
@@ -51,6 +51,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
});
it('expands RUS to all 84 sub-regions after the #31 + #41 Arkhangel splits (no ownership gap at game init)', () => {
// #31 split RU-ARK into the mainland + New Earth + Severnaya Zemlya; #41
// repartitioned the mainland into the core (unchanged key) + Pechora +
// Yamal. A fresh game's RUS territory (expanded from the preset's
// country list) must contain all five sub-region keys — the main one
// (ownership-safe, unchanged key) plus the island and band ones.
const rus = expandCountriesToKeys(['RUS']);
expect(rus).toHaveLength(84);
expect(rus).toContain('arkhangel-sk-lhbk');
expect(rus).toContain('new-earth-eikt');
expect(rus).toContain('severnaya-zemlya-awqp');
expect(rus).toContain('pechora-qwhs');
expect(rus).toContain('yamal-gosw');
});
it('referenceForKeys labels only known keys and omits unknown ones', () => {
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
@@ -166,13 +181,13 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
expect(loadRegions(prodRoot)).toBe(true);
expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796);
expect(validKeys().size).toBe(1800);
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
});
it('still loads from public/ (the dev layout, the first candidate)', () => {
expect(loadRegions(realRoot)).toBe(true);
expect(validKeys().size).toBe(1796);
expect(validKeys().size).toBe(1800);
});
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {