Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d194636d0 | ||
|
|
a93ece9474 | ||
|
|
44e54470e2 | ||
|
|
cdabf16052 | ||
|
|
7fbc00ebbe | ||
|
|
22805a3be6 | ||
|
|
327fffb789 | ||
|
|
a129c2ab81 | ||
|
|
aa1bd3c5aa | ||
|
|
f6ad554823 | ||
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 | ||
|
|
c63f6acca1 |
@@ -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,35 +56,44 @@ 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; 225 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)
|
||||
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
|
||||
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
|
||||
├── 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
|
||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
|
||||
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
|
||||
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
|
||||
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
|
||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
|
||||
│ # seedRegionStats / migrateRegionStats)
|
||||
├── server/
|
||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||
│ │ # PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3; per-region stats maintenance + derived recompute, v0.28 #46) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
||||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency/centroids/geo-manifest (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency, centroidOf/hasCentroids, geoOf/hasGeo (#46)
|
||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||
│ ├── presets.ts # load preset JSON files from disk
|
||||
│ ├── db.ts # SQLite open/save/load
|
||||
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
|
||||
│ └── *.test.ts # unit tests (vitest)
|
||||
└── src/
|
||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||||
@@ -110,11 +123,15 @@ 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/`; 225 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 build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||
| `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` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
|
||||
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
|
||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||
|
||||
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
||||
@@ -188,7 +205,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
|
||||
@@ -227,6 +259,74 @@ history/
|
||||
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
|
||||
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
|
||||
(`server/engine.test.ts`).
|
||||
- **Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.**
|
||||
The LLM can place named **point markers** on the map each turn: eight `MarkerType`s
|
||||
(`city, factory, army, port, capital, fleet, rocket, gathering`; a **capital** is at most one
|
||||
per nation, a **gathering** a political/social mass event that can enable a non-military
|
||||
takeover). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required,
|
||||
default `[]`; `MarkerSpec = { type, name, regionKey }`). A marker's stable `id` is the FNV-1a
|
||||
32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase
|
||||
base-26 characters (mirroring the region-key scheme) — so a **re-add is idempotent** and a
|
||||
**remove is unambiguous** (by id). `GameState.markers: MapMarker[]` is required (`[]` at game
|
||||
creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||
`applyTurnResult` handles markers **after** territory claims: (1) remove by id (absent id →
|
||||
no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim
|
||||
ownership** — a marker always follows its region (a captured region follows its new owner; a
|
||||
region released to no man's land, or a key with 0 or 2+ owners, becomes `null`); (3) add — each
|
||||
new spec is placed at its region's centroid (from `public/region-centroids.json`, injected via
|
||||
the optional `centroidOf` param on `applyTurnResult`) plus a deterministic per-axis jitter
|
||||
(`spread ≈ 0.1 × the largest part's smallest bbox dimension`, so multiple markers of one region
|
||||
scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting
|
||||
turn. Adds are **dropped, never thrown**, when the region key is unknown or has no centroid,
|
||||
when the id already exists, or beyond the hard cap `MAX_MARKERS = 400`. The system prompt
|
||||
documents the field + types + placement rules; `buildUserPrompt` carries the current markers
|
||||
(as `MarkerInfo[]`) so the model can reference their ids in `remove`; a marker add/remove makes
|
||||
a turn "meaningful". **Rendering is canvas-generated per-type ICON images, NOT text labels**
|
||||
(`src/components/WorldMap.tsx`): every external glyph/font endpoint is a dead 404 (OpenFreeMap's
|
||||
`fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`) and the OFM dark basemap only
|
||||
renders because it is **raster** (labels baked into the tiles) — there is no working vector
|
||||
font, so markers are drawn as self-contained, offline, deterministic per-type icons
|
||||
(`drawMarkerIcon`: distinct shape per type, filled with the type color, outlined for legibility),
|
||||
registered as named `ImageData` images that survive `styledata`. The marker **name shows in the
|
||||
hover tooltip** (not a persistent on-map label), and **clicking a marker focuses its owning
|
||||
nation's dossier**. There are **no separate marker event handlers** — MapLibre's per-layer event
|
||||
delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead
|
||||
`queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
||||
unaffected and the marker path is unit-tested only (`server/engine.test.ts`,
|
||||
`scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`).
|
||||
- **Per-region stats — `GameState.regionStats`, derived `Nation.stats` (v0.28, #46; PR 1 of 2 — the LLM lever is PR 2).**
|
||||
Since v0.28 the four 0–100 stats live at **region-key** granularity: `GameState.regionStats`
|
||||
(required; `{}` at game creation) maps each owned region key to its own `RegionStats`, and
|
||||
`Nation.stats` is **derived** — the **area×population-weighted median** of the stats of the
|
||||
regions the nation owns (`deriveNationStats`, pure in `shared/protocol.ts`). A region's weight
|
||||
is `(area>0?area:1)×(population>0?population:1)` from the committed `public/region-geo.json`
|
||||
manifest (1,800 entries; area in km² at 2dp, integer population) — which is built by
|
||||
`scripts/build-region-geo.mjs` from `regions.geo.json` + `region-keys.json` + the committed
|
||||
`public/ne-admin0-pop.json` (Natural Earth admin-0 `POP_EST`, 249 codes, one-time distilled by
|
||||
`scripts/extract-admin0-pop.mjs`; each country's `POP_EST` is spread over its sub-regions
|
||||
proportional to area by largest-remainder, so per-country totals are preserved exactly). A
|
||||
released / 0-owner / 2+-owner key has **no** entry. A fresh game is seeded by
|
||||
`seedRegionStats` (every owned region gets a **copy** of its owner's stats — identical values,
|
||||
so the derived median equals the original and fresh-game numbers are unchanged). Legacy SQLite
|
||||
rows are migrated once on load by `migrateRegionStats` (`db.load`, called without `geoOf` —
|
||||
weight-invariant for the identical-value seed): a row that already has `regionStats` (v0.28+)
|
||||
is returned **unchanged** (never recomputed on load → no drift), a legacy row is seeded +
|
||||
recomputed (0-region guard keeps its stats). `applyTurnResult` takes an optional `geoOf`
|
||||
(passed through `runTurn`; the jump route wires it via `hasGeo() ? (key) => geoOf(key) :
|
||||
undefined`) and maintains the table inside the **load-bearing order events → relations →
|
||||
territory → stateChanges → recompute → markers → nationOps**: a release deletes the key's
|
||||
entry; a (re-)claim re-inits an absent entry as a copy of the gainer's current `stats`;
|
||||
nation-level `stateChanges` (the only LLM lever for now — the prompt is **unchanged** in this
|
||||
PR) write to `n.stats` **and** to every region the nation owns; then every nation with ≥1
|
||||
entered region gets `stats` recomputed as its regions' weighted median (a 0-region — or no-
|
||||
entered-region — nation keeps its directly-maintained `stats`, which is why the existing unit
|
||||
and e2e assertions pass unchanged; without the geo manifest the recompute degrades to an
|
||||
unweighted median, never throwing). The stub never touches per-region stats (`LLM_STUB=1`
|
||||
e2e unaffected), and the path is unit-tested (`server/engine.test.ts`,
|
||||
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
||||
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||
when PR 2, the per-region LLM lever, ships).
|
||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||
@@ -347,7 +447,8 @@ history/
|
||||
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
|
||||
- The `dist/` client is committed to the build output only; the `public/` data files
|
||||
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
|
||||
ownership/adjacency manifests — and `countries.geo.json`) are copied into `dist/` by Vite at
|
||||
ownership/adjacency manifests — `region-centroids.json` — the marker anchors (#44) — and
|
||||
`countries.geo.json`) are copied into `dist/` by Vite at
|
||||
build time, so the prod image serves them from `dist/` (no separate copy). The server's region
|
||||
loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the
|
||||
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
|
||||
|
||||
@@ -2,6 +2,96 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.30.0 — 2026-10-15 08:22
|
||||
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
|
||||
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
|
||||
- **Types (`shared/types.ts`).** `EventOutcome` gains `'repelled'` (engine-only; the LLM never produces it).
|
||||
- **Colors (`src/lib/colors.ts`).** `OUTCOME_COLORS.repelled = '#9f1239'` — a muted rose-800, distinct from `failure` (`#ef4444`) and `neutral` (`#94a3b8`).
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `applyTurnResult` gains an optional `regionNameOf` (5th optional param, after `geoOf`) and the **military hard gate** inside the snapshot-first claims loop: for each contested key (a gainer's `gain` whose current sole owner is a *different* nation), it compares the gainer's start-of-turn military to the owner's via `canConquer`; on a fail it keeps the key with the owner, leaves both nations' military untouched, and pushes one `repelled` military event (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
|
||||
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
|
||||
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
|
||||
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
|
||||
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
|
||||
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
|
||||
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
|
||||
|
||||
## v0.29.0 — 2026-10-15 01:05
|
||||
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
|
||||
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `resolveRegionKey(ref, validKeys, nameOf)` — pure resolver: exact key first; then a case-insensitive unique-name match; ambiguous / unknown / empty → `null`.
|
||||
- `validateRegionChanges(raw, regionKeyResolver?)` — validates and resolves each entry; drops unknown / ambiguous refs (never throws); clamps `set` values at validation; `add` values are clamped at apply time.
|
||||
- `parseTurnResult` / `validateTurnResult` / `runTurn` all accept an **optional** `regionKeyResolver` (2nd arg / opts) threaded to `validateRegionChanges`; the generic "unclear" fallback carries `regionChanges: []`.
|
||||
- `applyTurnResult` applies `regionChanges` after `stateChanges` and before the weighted-median recompute (the load-bearing order is: events → relations → territory → stateChanges → **regionChanges** → recompute → markers → nationOps).
|
||||
- `isMeaningful` — a turn with only `regionChanges` is meaningful (survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — documents the `regionChanges` JSON field and a constraint bullet (target one region, use keys from the "regions" table, "playerRegionStats" lists the player's current per-region stats).
|
||||
- `buildUserPrompt` — computes and emits `playerRegionStats` (the player's owned regions' current stats; bounded to the player's territory to keep the prompt small; `{}` when none).
|
||||
- **Server route (`server/index.ts`).** The `/jump` route wires the `regionKeyResolver` (always on; degrades to key-only matching when the adjacency manifest is absent).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `regionChanges: []` — the per-region lever is covered by unit tests only, keeping the e2e assertions untouched.
|
||||
- **Client (`src/components/WorldMap.tsx`).** The hover tooltip gains a `regionStats` prop and shows the region's own four stats (`Mil / Eco / Stab / Pop`); a region with no entry shows a `–` placeholder. The name and owner chip are kept; marker rendering and the click→dossier path are unchanged. `src/App.tsx` passes `game.regionStats ?? {}`.
|
||||
- **Unit tests (`server/engine.test.ts`).** New suites: `resolveRegionKey` (key match, case-insensitive name match, unknown / ambiguous → null, key-over-name priority); `validateRegionChanges` (key / name resolution, unknown / invalid stat / non-numeric / invalid op dropped, `set` clamped, `add` not clamped at validation, non-array → [], no-resolver path); `applyTurnResult` with `regionChanges` (set changes only that region, add clamps 0/100, unowned no-op, applied after stateChanges, non-mutation); prompt contract (system prompt documents the field + constraint, user prompt emits bounded `playerRegionStats`); `isMeaningful` with `regionChanges`; stub returns `regionChanges: []`.
|
||||
|
||||
## v0.28.0 — 2026-10-15 00:12
|
||||
- **#46 (PR 1 of 2): per-region stats — a nation's four 0–100 stats become a *derived* aggregate.** Instead of storing `Nation.stats` as four independent per-nation numbers, they are now **derived** as the **area×population-weighted median** of the stats of the regions the nation owns. Each sub-region carries its own `RegionStats` in a new `GameState.regionStats: Record<regionKey, RegionStats>` table; a nation's displayed stats are the weighted median over its owned regions. This is the data foundation for the per-region LLM levers that land in **PR 2** — in this PR the LLM still only emits nation-level `stateChanges`, which are re-interpreted as applying to *all* of a nation's owned regions. A new `public/region-geo.json` manifest (area km² + population per region key) supplies the weighting. **Not deployed in this PR** — the production site updates when PR 2 ships (the change is additive and backward-compatible, so deferring the deploy is safe).
|
||||
- **Data build (`scripts/`, turf-`.mjs` only; never imported by `.ts`).**
|
||||
- `scripts/extract-admin0-pop.mjs` (`npm run extract:admin0-pop`) — **one-time** distillation of the 13 MB Natural Earth 1:10m admin-0 GeoJSON (a *source* file we do **not** commit) into the small committed `public/ne-admin0-pop.json` = `{ ADM0_A3 → max POP_EST }` (249 country codes, total 7,677,059,722). The 1:10m **admin-1** file the region generator uses carries no population, so the source is the **admin-0** `POP_EST` (a deliberate deviation, recorded here).
|
||||
- `scripts/build-region-geo.mjs` (`npm run build:region-geo`) — builds `public/region-geo.json` = `{ regionKey → { area, population } }`: `area` = `@turf/area(finalFeature)/1e6` km² (2 dp); `population` = the parent country's `POP_EST` spread across its sub-regions **proportional to area via largest-remainder**, so every country's sub-regions sum **exactly** to its total (no rounding drift). Reads the committed `ne-admin0-pop.json` → fully reproducible in CI. **Deterministic + idempotent** (sorted keys; byte-identical re-run, pinned by the test). 1,800 entries; **total population 7,654,092,021** (the sum over the 177 repo countries).
|
||||
- **Types (`shared/types.ts`, no runtime deps).** New `RegionStats = { military, economy, stability, population }` (0–100). `GameState.regionStats: Record<string, RegionStats>` is **required** (`{}` at game creation; a released / 0-owner / 2+owner region has **no** entry; a legacy SQLite row without the field is migrated on load, never wiped).
|
||||
- **Pure helpers (`shared/protocol.ts`, no deps).**
|
||||
- `weightedMedian(values, weights)` — sorts by value and returns the value at the first point the cumulative weight reaches half the total; non-finite/non-positive weights are excluded; empty / zero-total → 0. **Translation-invariant** (the median of identical values is that value — the invariant seeding and migration rely on).
|
||||
- `deriveNationStats(regionKeys, regionStats, geoOf?)` — each stat is the weighted median over the nation's owned region keys; a region's weight is `(area>0?area:1)×(population>0?population:1)` (both fall back to 1 when the key has no geo entry, so missing geo degrades to a uniform median); a key with no `regionStats` entry is skipped; returns zero stats when no owned region has an entry (the caller's 0-region guard keeps `n.stats` in that case).
|
||||
- `seedRegionStats(nations)` — seeds the table from nations: every owned key gets a **copy** of its owner's stats (first owner wins on a corrupt double-ownership), so a fresh nation's regions start identical (derived = seeded).
|
||||
- `migrateRegionStats(state, geoOf?)` — **one-time** legacy migration: a row that already has `regionStats` is returned **unchanged** (no recompute on load → no drift); a legacy row (field absent) is seeded per region and each nation's stats recomputed (0-region guard). Never throws.
|
||||
- **Server (`server/regions.ts`, turf-free).** Loads `region-geo.json` (probes `public/` then `dist/`) **optionally** — a missing/broken file degrades weighting to uniform (every region weight 1) while everything else keeps working. New `hasGeo()` and `geoOf(key)` accessors mirror the existing `hasCentroids`/`centroidOf`.
|
||||
- **Server (`server/engine.ts`).** `applyTurnResult` gains an optional `geoOf` param and is reordered to: events → relations → **territory** → **stateChanges** → **recompute** → markers → nationOps.
|
||||
- **Territory:** a **transferred** region **keeps its own** per-region stats (the conquest is what makes a nation's regions diverge); a **released** region's entry is **deleted** (no man's land carries none); a **re-claimed** region with no entry is **re-inited** to the gainer's current stats.
|
||||
- **StateChanges (re-interpreted):** a nation-level `set`/`add` applies to `n.stats` **and** to every region the nation owns (clamped 0–100).
|
||||
- **Recompute:** every nation's `stats` is re-derived via `deriveNationStats` (weighted by area×population when `geoOf` is present). Two guards: a **0-region** nation keeps its directly-maintained stats, and a nation **none of whose regions has an entry** keeps its stats too (avoids clobbering with zeros).
|
||||
- `runTurn` threads the optional `geoOf` through to `applyTurnResult`.
|
||||
- **Server (`server/index.ts`).** Game creation seeds `regionStats` via `seedRegionStats` **after** `expandCountriesToKeys` (so regions hold real keys) — a new game's derived stats equal its nation-level stats on turn 1. The `jump` route passes `geoOf: hasGeo() ? geoOf : undefined`.
|
||||
- **Server (`server/db.ts`).** `load()` migrates a legacy row (regionStats absent) via `migrateRegionStats(state)` **without** `geoOf` (uniform weights — weight-invariant for the equal-value seeding, and avoids a `db`→`regions` import). v0.28+ rows load unchanged.
|
||||
- **Prompt / client / stub unchanged.** `nationForPrompt`/`buildUserPrompt` still surface the (now derived) four stats; there is no per-region LLM lever yet (PR 2). The deterministic stub is unaffected (nation-level `stateChanges` flow through the re-interpretation; the median of identical values is that value, so e2e stat assertions hold).
|
||||
- **Tests (189 → 225).** New `server/protocol.test.ts` (weightedMedian incl. translation-invariance / identical / zero / empty; deriveNationStats weighted vs uniform / skip-missing / zero / single / fallback-weight; seedRegionStats; migrateRegionStats v0.28+ no-op vs legacy / 0-region); `server/engine.test.ts` (transfer keeps region stats + aggregate blends, release deletes entry, re-claim re-inits, 0-region keeps stats, geoOf-weighted vs uniform recompute, nation-level change re-applies to regions, original not mutated); `server/db.test.ts` (legacy row → seeded + preserved stats; v0.28+ row loads unchanged); `server/regions.test.ts` (`hasGeo`/`geoOf` in dev + prod-`dist/` layouts, graceful degradation when absent); new `scripts/region-geo.test.ts` (1,800-entry key-set match, sorted, finite / ≤2dp area / integer population, total 7,654,092,021, per-country totals preserved vs admin-0, spot-checked values, byte-identical re-run).
|
||||
- **Verification.** Typecheck, lint, **225 unit tests**, and the production build all pass (the build copies `region-geo.json` + `ne-admin0-pop.json` into `dist/` like the other manifests, so the prod container layout is covered). e2e is verified by CI on the PR (no local run).
|
||||
- **Deploy:** **none** in this PR — the production site (`https://history.dev.kkhost.pl`) updates when **PR 2** ships. The change is additive + backward-compatible, so existing games migrate on load and no data wipe is needed.
|
||||
|
||||
## v0.27.0 — 2026-10-14 22:34
|
||||
- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer.
|
||||
- **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`.
|
||||
- **Stable ids (`shared/protocol.ts`).** A marker's `id` is the FNV-1a 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase base-26 characters (mirroring the region-key scheme). This makes a **re-add idempotent** (the same type+name+region never creates a second marker) and a **remove unambiguous** (by id).
|
||||
- **Centroid data (`public/region-centroids.json`, new).** ~1,800 entries keyed identically to `region-keys.json`, each `{ lat, lon, spread }`: the anchor is the **centroid of the region's largest-area part** (a single representative point, not an average across disjoint parts), and `spread` ≈ **0.1 × that part's smallest bbox dimension** (in degrees). A new idempotent pipeline stage `scripts/build-region-centroids.mjs` (`npm run build:region-centroids`) regenerates it deterministically (byte-identical re-run, sha256-pinned by the test). The engine uses the centroid as the marker's base position and the `spread` to jitter multiple markers of one region around it (a deterministic per-axis FNV-1a-derived offset, so markers scatter instead of stacking).
|
||||
- **Engine (`server/engine.ts`).** `applyTurnResult` processes markers **after** territory claims, in three steps: (1) remove by id (a listed-but-absent id is a no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim ownership** — a captured region follows its new owner, a region released to no man's land (or a key with 0 or 2+ owners) becomes `null` (a marker always follows its region); (3) add — each new spec is placed at its region's centroid (injected via a new optional `centroidOf` param) plus the per-axis jitter, owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped (never thrown) when their region key is unknown / has no centroid, when the id already exists (idempotent re-add), or beyond the hard cap `MAX_MARKERS = 400` (later adds dropped). The system prompt documents the field, the eight types, and the placement rules; `buildUserPrompt` carries the current markers (as `MarkerInfo[]`) so the model can reference their stable ids in `remove`; `isMeaningful` treats a marker add/remove as a real world change.
|
||||
- **Client (`src/components/WorldMap.tsx`).** A second GeoJSON source + a symbol layer sits above the choropleth. **Deliberate deviation from the original "name as a text label" design:** every external glyph endpoint is a dead 404 (OpenFreeMap's `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`), and the OFM dark basemap only renders at all because it is **raster** (labels baked into the PNG tiles) — there is no working vector font. So markers are drawn as **self-contained, offline, canvas-generated per-type icons** (`drawMarkerIcon`: a distinct shape per type — circle / rounded-square / triangle / diamond / star / center-dotted circles / up-arrow — filled with the type color, outlined for legibility on the dark basemap), registered as named `ImageData` images that survive `styledata`. The marker **name is shown in the hover tooltip** (region name + owner chip + marker name) instead of a persistent on-map label, and **clicking a marker focuses its owning nation's dossier** (a marker's owner is its region's owner). There are no separate marker event handlers: MapLibre's per-layer event delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||
- **Stub (`server/stub.ts`) stays marker-free** — it returns `markers: { add: [], remove: [] }` — so the e2e suite (CI, `LLM_STUB=1`) is unaffected and fully deterministic; the full marker path is covered by unit tests only.
|
||||
- **Tests (168 → 189).** New `scripts/region-centroids.test.ts` (1,800 entries, key-set match, finite/world-bounds, each centroid inside its feature's bbox via turf-free coordinate iteration, spot-checked anchors, byte-identical re-run determinism); `server/regions.test.ts` (`hasCentroids`/`centroidOf` in dev + prod-`dist/` layouts, graceful degradation when absent); `server/db.test.ts` (legacy row → `markers: []`); and an `engine.test.ts` marker suite (valid add → id/position/owner/placedTurn, idempotent re-add, region-validity/centroid drops, the 400 cap, remove by id / no-op, owner-follows-region incl. release→null and re-claim, `buildUserPrompt`/system-prompt contract, `validateMarkerOps` raw cleaner, and the `runTurn` centroid wiring).
|
||||
- **Verification.** Typecheck, lint, **189 unit tests**, and the production build all pass (the build copies `region-centroids.json` into `dist/` like the other manifests, so it works in the prod container layout).
|
||||
- **Deploy:** manual `docker compose up -d --build` on the host — **no `down -v`** (markers are additive; legacy games load with `markers: []`).
|
||||
|
||||
## 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.
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.24.0",
|
||||
"version": "0.30.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -14,8 +14,13 @@
|
||||
"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",
|
||||
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||
"build:region-geo": "node scripts/build-region-geo.mjs",
|
||||
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"ABW": 106314,
|
||||
"AFG": 38041754,
|
||||
"AGO": 31825295,
|
||||
"AIA": 14731,
|
||||
"ALB": 2854191,
|
||||
"ALD": 29884,
|
||||
"AND": 77142,
|
||||
"ARE": 9770529,
|
||||
"ARG": 44938712,
|
||||
"ARM": 2957731,
|
||||
"ASM": 55312,
|
||||
"ATA": 4490,
|
||||
"ATF": 140,
|
||||
"ATG": 97118,
|
||||
"AUS": 25364307,
|
||||
"AUT": 8877067,
|
||||
"AZE": 10023318,
|
||||
"BDI": 11530580,
|
||||
"BEL": 11484055,
|
||||
"BEN": 11801151,
|
||||
"BFA": 20321378,
|
||||
"BGD": 163046161,
|
||||
"BGR": 6975761,
|
||||
"BHR": 1641172,
|
||||
"BHS": 389482,
|
||||
"BIH": 3301000,
|
||||
"BLM": 9961,
|
||||
"BLR": 9466856,
|
||||
"BLZ": 390353,
|
||||
"BMU": 63918,
|
||||
"BOL": 11513100,
|
||||
"BRA": 211049527,
|
||||
"BRB": 287025,
|
||||
"BRN": 433285,
|
||||
"BTN": 763092,
|
||||
"BWA": 2303697,
|
||||
"CAF": 4745185,
|
||||
"CAN": 37589262,
|
||||
"CHE": 8574832,
|
||||
"CHL": 18952038,
|
||||
"CHN": 1397715000,
|
||||
"CIV": 25716544,
|
||||
"CMR": 25876380,
|
||||
"CNM": 10000,
|
||||
"COD": 86790567,
|
||||
"COG": 5380508,
|
||||
"COK": 17459,
|
||||
"COL": 50339443,
|
||||
"COM": 850886,
|
||||
"CPV": 549935,
|
||||
"CRI": 5047561,
|
||||
"CSI": 4,
|
||||
"CUB": 11333483,
|
||||
"CUW": 157538,
|
||||
"CYM": 64948,
|
||||
"CYN": 326000,
|
||||
"CYP": 1198575,
|
||||
"CZE": 10669709,
|
||||
"DEU": 83132799,
|
||||
"DJI": 973560,
|
||||
"DMA": 71808,
|
||||
"DNK": 5818553,
|
||||
"DOM": 10738958,
|
||||
"DZA": 43053054,
|
||||
"ECU": 17373662,
|
||||
"EGY": 100388073,
|
||||
"ERI": 6081196,
|
||||
"ESB": 7850,
|
||||
"ESP": 47076781,
|
||||
"EST": 1326590,
|
||||
"ETH": 112078730,
|
||||
"FIN": 5520314,
|
||||
"FJI": 889953,
|
||||
"FLK": 3398,
|
||||
"FRA": 67059887,
|
||||
"FRO": 48678,
|
||||
"FSM": 113815,
|
||||
"GAB": 2172579,
|
||||
"GBR": 66834405,
|
||||
"GEO": 3720382,
|
||||
"GGY": 62792,
|
||||
"GHA": 30417856,
|
||||
"GIB": 33701,
|
||||
"GIN": 12771246,
|
||||
"GMB": 2347706,
|
||||
"GNB": 1920922,
|
||||
"GNQ": 1355986,
|
||||
"GRC": 10716322,
|
||||
"GRD": 112003,
|
||||
"GRL": 56225,
|
||||
"GTM": 16604026,
|
||||
"GUM": 167294,
|
||||
"GUY": 782766,
|
||||
"HKG": 7507400,
|
||||
"HND": 9746117,
|
||||
"HRV": 4067500,
|
||||
"HTI": 11263077,
|
||||
"HUN": 9769949,
|
||||
"IDN": 270625568,
|
||||
"IMN": 84584,
|
||||
"IND": 1366417754,
|
||||
"IOA": 2387,
|
||||
"IOT": 3000,
|
||||
"IRL": 4941444,
|
||||
"IRN": 82913906,
|
||||
"IRQ": 39309783,
|
||||
"ISL": 361313,
|
||||
"ISR": 9053300,
|
||||
"ITA": 60297396,
|
||||
"JAM": 2948279,
|
||||
"JEY": 107800,
|
||||
"JOR": 10101694,
|
||||
"JPN": 126264931,
|
||||
"KAB": 36175,
|
||||
"KAS": 6000,
|
||||
"KAZ": 18513930,
|
||||
"KEN": 52573973,
|
||||
"KGZ": 6456900,
|
||||
"KHM": 16486542,
|
||||
"KIR": 117606,
|
||||
"KNA": 52834,
|
||||
"KOR": 51709098,
|
||||
"KOS": 1794248,
|
||||
"KWT": 4207083,
|
||||
"LAO": 7169455,
|
||||
"LBN": 6855713,
|
||||
"LBR": 4937374,
|
||||
"LBY": 6777452,
|
||||
"LCA": 182790,
|
||||
"LIE": 38019,
|
||||
"LKA": 21803000,
|
||||
"LSO": 2125268,
|
||||
"LTU": 2786844,
|
||||
"LUX": 619896,
|
||||
"LVA": 1912789,
|
||||
"MAC": 640445,
|
||||
"MAF": 38002,
|
||||
"MAR": 36471769,
|
||||
"MCO": 38964,
|
||||
"MDA": 2657637,
|
||||
"MDG": 26969307,
|
||||
"MDV": 530953,
|
||||
"MEX": 127575529,
|
||||
"MHL": 58791,
|
||||
"MKD": 2083459,
|
||||
"MLI": 19658031,
|
||||
"MLT": 502653,
|
||||
"MMR": 54045420,
|
||||
"MNE": 622137,
|
||||
"MNG": 3225167,
|
||||
"MNP": 57216,
|
||||
"MOZ": 30366036,
|
||||
"MRT": 4525696,
|
||||
"MSR": 4649,
|
||||
"MUS": 1265711,
|
||||
"MWI": 18628747,
|
||||
"MYS": 31949777,
|
||||
"NAM": 2494530,
|
||||
"NCL": 287800,
|
||||
"NER": 23310715,
|
||||
"NFK": 2169,
|
||||
"NGA": 200963599,
|
||||
"NIC": 6545502,
|
||||
"NIU": 1620,
|
||||
"NLD": 17332850,
|
||||
"NOR": 5347896,
|
||||
"NPL": 28608710,
|
||||
"NRU": 12581,
|
||||
"NZL": 4917000,
|
||||
"OMN": 4974986,
|
||||
"PAK": 216565318,
|
||||
"PAN": 4246439,
|
||||
"PCN": 54,
|
||||
"PER": 32510453,
|
||||
"PGA": 100,
|
||||
"PHL": 108116615,
|
||||
"PLW": 18008,
|
||||
"PNG": 8776109,
|
||||
"POL": 37970874,
|
||||
"PRI": 3193694,
|
||||
"PRK": 25666161,
|
||||
"PRT": 10269417,
|
||||
"PRY": 7044636,
|
||||
"PSX": 4685306,
|
||||
"PYF": 279287,
|
||||
"QAT": 2832067,
|
||||
"ROU": 19356544,
|
||||
"RUS": 144373535,
|
||||
"RWA": 12626950,
|
||||
"SAH": 603253,
|
||||
"SAU": 34268528,
|
||||
"SDN": 42813238,
|
||||
"SDS": 11062113,
|
||||
"SEN": 16296364,
|
||||
"SGP": 5703569,
|
||||
"SGS": 30,
|
||||
"SHN": 4534,
|
||||
"SLB": 669823,
|
||||
"SLE": 7813215,
|
||||
"SLV": 6453553,
|
||||
"SMR": 33860,
|
||||
"SOL": 5096159,
|
||||
"SOM": 10192317,
|
||||
"SPM": 5997,
|
||||
"SRB": 6944975,
|
||||
"STP": 215056,
|
||||
"SUR": 581363,
|
||||
"SVK": 5454073,
|
||||
"SVN": 2087946,
|
||||
"SWE": 10285453,
|
||||
"SWZ": 1148130,
|
||||
"SXM": 40733,
|
||||
"SYC": 97625,
|
||||
"SYR": 17070135,
|
||||
"TCA": 38191,
|
||||
"TCD": 15946876,
|
||||
"TGO": 8082366,
|
||||
"THA": 69625582,
|
||||
"TJK": 9321018,
|
||||
"TKM": 5942089,
|
||||
"TLS": 1293119,
|
||||
"TON": 104494,
|
||||
"TTO": 1394973,
|
||||
"TUN": 11694719,
|
||||
"TUR": 83429615,
|
||||
"TUV": 11646,
|
||||
"TWN": 23568378,
|
||||
"TZA": 58005463,
|
||||
"UGA": 44269594,
|
||||
"UKR": 44385155,
|
||||
"UMI": 300,
|
||||
"URY": 3461734,
|
||||
"USA": 328239523,
|
||||
"USG": 6000,
|
||||
"UZB": 33580650,
|
||||
"VAT": 825,
|
||||
"VCT": 110589,
|
||||
"VEN": 28515829,
|
||||
"VGB": 30030,
|
||||
"VIR": 106631,
|
||||
"VNM": 96462106,
|
||||
"VUT": 299882,
|
||||
"WLF": 11558,
|
||||
"WSB": 7850,
|
||||
"WSM": 197097,
|
||||
"YEM": 29161922,
|
||||
"ZAF": 58558270,
|
||||
"ZMB": 17861030,
|
||||
"ZWE": 14645468
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* scripts/build-region-centroids.mjs — build the precomputed region-centroid
|
||||
* manifest: `public/region-centroids.json` = `{ "<regionKey>": { "lat": <number>, "lon": <number>, "spread": <number> }, ... }`.
|
||||
*
|
||||
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||
* the point the LLM's map markers (v0.27, #44) will be anchored to: the
|
||||
* **centroid of the region's largest-area part** (a MultiPolygon's biggest
|
||||
* island/lake-piece rather than an area-weighted average of all parts), plus
|
||||
* a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in
|
||||
* degrees) — the engine scatters markers of one region within ±(spread/2)
|
||||
* around the centroid using a deterministic per-marker offset, so several
|
||||
* markers of the same region do not stack on one pixel.
|
||||
*
|
||||
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||
* v0.17 note). For each feature we (1) split a MultiPolygon into its parts
|
||||
* (a plain Polygon is its own single part), (2) pick the part with the
|
||||
* greatest `@turf/area`, (3) take `@turf/centroid` of that part and
|
||||
* `@turf/bbox` of that part, and (4) round everything to 6 decimal places
|
||||
* (≈ 0.1 m at this scale — far below the map's visual precision).
|
||||
*
|
||||
* Output contract (asserted by scripts/region-centroids.test.ts):
|
||||
* - one entry for **every** region key, keyed identically to
|
||||
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||
* - each centroid lies inside its region's (largest part's) bbox;
|
||||
* - `spread` is a non-negative number of degrees (0 for degenerate parts);
|
||||
* - values are rounded to at most 6 decimal places.
|
||||
*
|
||||
* Deterministic + idempotent: the input files are fixed, turf is a pure
|
||||
* geometric computation, the output keeps the geo file's (stable) feature
|
||||
* order and rounds to fixed precision — so a re-run rewrites a byte-identical
|
||||
* file (the test re-runs this script and compares bytes).
|
||||
*
|
||||
* Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import area from '@turf/area';
|
||||
import bbox from '@turf/bbox';
|
||||
import centroid from '@turf/centroid';
|
||||
import { feature } from '@turf/helpers';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
|
||||
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||||
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||||
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||||
|
||||
/**
|
||||
* The polygon parts of a geometry: a MultiPolygon's individual polygons, a
|
||||
* plain Polygon as its own single part, anything else as an empty list (the
|
||||
* caller falls back to the whole-feature centroid).
|
||||
*/
|
||||
function partsOf(geometry) {
|
||||
if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? [];
|
||||
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||||
return [];
|
||||
}
|
||||
|
||||
const manifest = {};
|
||||
let skipped = 0;
|
||||
for (const f of geo.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
const parts = partsOf(f.geometry);
|
||||
if (parts.length === 0) {
|
||||
// Degenerate geometry (a point/line region): anchor at the feature's own
|
||||
// centroid. Can't happen with the current 1,800-polygon dataset, but the
|
||||
// fallback keeps the manifest complete if the generator ever changes.
|
||||
skipped += 1;
|
||||
const c = centroid(feature(f.geometry));
|
||||
const b = bbox(feature(f.geometry));
|
||||
manifest[key] = {
|
||||
lat: round6(c.geometry.coordinates[1]),
|
||||
lon: round6(c.geometry.coordinates[0]),
|
||||
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Pick the part with the greatest area (km²) and anchor to IT, so a
|
||||
// multi-part region's markers cluster on its main mass, not between parts.
|
||||
let best = 0;
|
||||
let bestArea = -1;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const a = area(feature({ type: 'Polygon', coordinates: parts[i] }));
|
||||
if (a > bestArea) {
|
||||
bestArea = a;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
const part = feature({ type: 'Polygon', coordinates: parts[best] });
|
||||
const c = centroid(part);
|
||||
const b = bbox(part);
|
||||
manifest[key] = {
|
||||
lat: round6(c.geometry.coordinates[1]),
|
||||
lon: round6(c.geometry.coordinates[0]),
|
||||
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||
const expectedKeys = Object.values(idToKey);
|
||||
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||
throw new Error(
|
||||
`expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||
);
|
||||
}
|
||||
for (const k of expectedKeys) {
|
||||
if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`);
|
||||
}
|
||||
for (const [k, v] of Object.entries(manifest)) {
|
||||
if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) {
|
||||
throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`);
|
||||
}
|
||||
if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`);
|
||||
}
|
||||
|
||||
// --- write idempotently (stable order = insertion order = geo file order) ---
|
||||
writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
// --- summary ------------------------------------------------------------------
|
||||
const spreads = Object.values(manifest).map((v) => v.spread);
|
||||
console.log(
|
||||
`[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` +
|
||||
(skipped ? ` (${skipped} non-polygon fallbacks)` : '') +
|
||||
`, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`,
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* scripts/build-region-geo.mjs — build the region **geo** manifest:
|
||||
* `public/region-geo.json` = `{ "<regionKey>": { "area": <number>, "population": <number> }, ... }`.
|
||||
*
|
||||
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||
* two facts the v0.28 per-region stats model (and its area×population weighting)
|
||||
* needs:
|
||||
* - `area` — the region's **total** surface area in km², from
|
||||
* `@turf/area` on the FINAL generated feature
|
||||
* (`public/regions.geo.json`) — turf reports square metres,
|
||||
* so the raw value is divided by 1e6 — rounded to 2 decimals.
|
||||
* - `population` — the region's share of its **parent country's** real
|
||||
* population (Natural Earth 1:10m admin-0 `POP_EST`, from the
|
||||
* committed `public/ne-admin0-pop.json`), distributed across
|
||||
* the country's sub-regions **proportional to each region's
|
||||
* area** (a first-order "uniform population density within a
|
||||
* country" approximation — Natural Earth admin-1, which the
|
||||
* region generator uses, carries no per-unit population).
|
||||
*
|
||||
* Population distribution uses **largest remainder** per country, so the sum of
|
||||
* a country's sub-region populations is EXACTLY the country's POP_EST, and the
|
||||
* sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries'
|
||||
* POP_EST (7,654,092,021) — no rounding drift.
|
||||
*
|
||||
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||
* v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json`
|
||||
* (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from
|
||||
* the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build
|
||||
* is fully reproducible in CI.
|
||||
*
|
||||
* Output contract (asserted by scripts/region-geo.test.ts):
|
||||
* - one entry for **every** region key, keyed identically to
|
||||
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||
* - `area` is a finite number >= 0 (km², <= 2 decimals);
|
||||
* - `population` is a non-negative integer;
|
||||
* - the total population across all entries is a non-negative integer.
|
||||
*
|
||||
* Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and
|
||||
* the committed population table), pure turf, largest-remainder with a
|
||||
* (remainder desc, key asc) tie-break, and stable sorted key order → a re-run
|
||||
* rewrites a byte-identical file (the test re-runs this script and compares
|
||||
* bytes).
|
||||
*
|
||||
* Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import area from '@turf/area';
|
||||
import { feature } from '@turf/helpers';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
const round2 = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
// --- load the inputs (plain parse) ------------------------------------------
|
||||
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
||||
const keysPath = path.join(root, 'public', 'region-keys.json');
|
||||
const popPath = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||
|
||||
let geo, idToKey, popByCountry;
|
||||
try {
|
||||
geo = JSON.parse(readFileSync(geoPath, 'utf8'));
|
||||
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
|
||||
popByCountry = JSON.parse(readFileSync(popPath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` +
|
||||
`Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over the geo features: for each region key record its (unrounded)
|
||||
* turf area and its parent ISO_A3, then group the keys by parent so the
|
||||
* population can be distributed per country.
|
||||
*/
|
||||
const regionArea = new Map(); // key -> unrounded area (km²)
|
||||
const regionParent = new Map(); // key -> parent ISO_A3
|
||||
for (const f of geo.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
// @turf/area reports square METRES; the manifest stores km².
|
||||
const a = area(feature(f.geometry)) / 1e6;
|
||||
regionArea.set(key, a);
|
||||
regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : '');
|
||||
}
|
||||
|
||||
// Group region keys by parent country (file order is stable, but we sort for
|
||||
// deterministic population distribution).
|
||||
const keysByCountry = new Map(); // parent -> string[] of keys
|
||||
for (const [key, parent] of regionParent) {
|
||||
if (!parent) continue; // a region with no parent has no population source
|
||||
const list = keysByCountry.get(parent);
|
||||
if (list) list.push(key);
|
||||
else keysByCountry.set(parent, [key]);
|
||||
}
|
||||
|
||||
const population = new Map(); // key -> integer population
|
||||
for (const [parent, keys] of keysByCountry) {
|
||||
keys.sort(); // deterministic ordering for the tie-break
|
||||
const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0;
|
||||
const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0);
|
||||
if (P <= 0 || totalArea <= 0) {
|
||||
for (const k of keys) population.set(k, 0);
|
||||
continue;
|
||||
}
|
||||
// Largest remainder: floor each share, then hand out the leftover 1s to the
|
||||
// regions with the largest fractional remainders (ties broken by key asc).
|
||||
const shares = keys.map((k) => {
|
||||
const a = regionArea.get(k) || 0;
|
||||
const ideal = (P * a) / totalArea;
|
||||
return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) };
|
||||
});
|
||||
const distributed = shares.reduce((s, x) => s + x.floor, 0);
|
||||
let deficit = P - distributed; // 0 <= deficit < keys.length
|
||||
const order = shares
|
||||
.map((x, i) => ({ i, rem: x.rem, k: x.k }))
|
||||
.sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
|
||||
for (let i = 0; i < order.length && deficit > 0; i++) {
|
||||
shares[order[i].i].floor += 1;
|
||||
deficit -= 1;
|
||||
}
|
||||
for (const x of shares) population.set(x.k, x.floor);
|
||||
}
|
||||
|
||||
// --- assemble the manifest (sorted keys) -------------------------------------
|
||||
const expectedKeys = Object.values(idToKey);
|
||||
const manifest = {};
|
||||
for (const k of [...expectedKeys].sort()) {
|
||||
const a = regionArea.get(k);
|
||||
const p = population.get(k);
|
||||
if (a === undefined || p === undefined) {
|
||||
throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`);
|
||||
}
|
||||
manifest[k] = { area: round2(a), population: p };
|
||||
}
|
||||
|
||||
// --- hard assertions (the test re-checks these against the committed file) ----
|
||||
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||
throw new Error(
|
||||
`expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||
);
|
||||
}
|
||||
let totalPop = 0;
|
||||
let zeros = 0;
|
||||
for (const [k, v] of Object.entries(manifest)) {
|
||||
if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`);
|
||||
if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`);
|
||||
if (v.area === 0) zeros += 1;
|
||||
totalPop += v.population;
|
||||
}
|
||||
|
||||
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
|
||||
writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
// --- summary ------------------------------------------------------------------
|
||||
console.log(
|
||||
`[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` +
|
||||
`${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`,
|
||||
);
|
||||
@@ -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(', ')}`);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* scripts/extract-admin0-pop.mjs — one-time extraction of the population
|
||||
* SOURCE for the region-geo manifest: `public/ne-admin0-pop.json`.
|
||||
*
|
||||
* Output = `{ "<ADM0_A3>": <POP_EST>, ... }` — the estimated population
|
||||
* (POP_EST) of every Natural Earth 1:10m **admin-0 country** code, keeping
|
||||
* the **maximum** POP_EST seen for each code (Natural Earth ships a few
|
||||
* duplicated / broken-out features per code, e.g. the `TLC` stubs, whose
|
||||
* POP_EST is 0; the real country always wins by having the larger value).
|
||||
* Only codes with a finite, positive POP_EST are recorded (a 0/null POP_EST
|
||||
* has nothing to distribute). Keys are sorted; the file is written with
|
||||
* 2-space indent + trailing newline (deterministic, committed).
|
||||
*
|
||||
* Why a separate committed file: `public/region-geo.json` (built by
|
||||
* `build-region-geo.mjs`) needs a per-country population, but the raw
|
||||
* Natural Earth GeoJSON (13 MB) is a *source* file we deliberately do NOT
|
||||
* commit (see the AGENTS.md data-pipeline note). This script distils it
|
||||
* into a tiny (a few KB) derived table that IS committed, so the region-geo
|
||||
* builder stays fully reproducible in CI without the source present.
|
||||
* Re-run only when the underlying Natural Earth data changes.
|
||||
*
|
||||
* The 1:10m admin-1 file (the one the region generator uses) carries NO
|
||||
* population; the 1:10m admin-0 file does (uppercase `POP_EST` / `ADM0_A3`).
|
||||
*
|
||||
* Usage:
|
||||
* NE_ADMIN0=/path/to/ne_10m_admin_0_countries.geojson node scripts/extract-admin0-pop.mjs
|
||||
* (default source: /tmp/ne10m_admin0.geojson)
|
||||
*/
|
||||
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 src = process.env.NE_ADMIN0 || '/tmp/ne10m_admin0.geojson';
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(src, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[extract-admin0-pop] could not read the Natural Earth admin-0 source at ${src}. ` +
|
||||
`Download it (ne_10m_admin_0_countries.geojson) and re-run with NE_ADMIN0 pointing at it.`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const geo = JSON.parse(raw);
|
||||
|
||||
/** ADM0_A3 -> max finite, positive POP_EST. */
|
||||
const byCode = new Map();
|
||||
for (const f of geo.features ?? []) {
|
||||
const p = f.properties ?? {};
|
||||
const code = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : '';
|
||||
const pop = typeof p.POP_EST === 'number' ? p.POP_EST : NaN;
|
||||
if (!code || !Number.isFinite(pop) || pop <= 0) continue;
|
||||
const prev = byCode.get(code);
|
||||
if (prev === undefined || pop > prev) byCode.set(code, Math.round(pop));
|
||||
}
|
||||
|
||||
if (byCode.size === 0) {
|
||||
throw new Error('[extract-admin0-pop] no finite positive POP_EST found in the source — wrong file?');
|
||||
}
|
||||
|
||||
// Deterministic: sorted keys, 2-space indent, trailing newline.
|
||||
const keys = [...byCode.keys()].sort();
|
||||
const manifest = {};
|
||||
for (const k of keys) manifest[k] = byCode.get(k);
|
||||
|
||||
const out = path.join(root, 'public', 'ne-admin0-pop.json');
|
||||
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
let sum = 0;
|
||||
for (const v of byCode.values()) sum += v;
|
||||
console.log(
|
||||
`[extract-admin0-pop] wrote public/ne-admin0-pop.json — ${byCode.size} country codes, ` +
|
||||
`total population ${sum.toLocaleString('en-US')} (Natural Earth 1:10m admin-0, max POP_EST per code)`,
|
||||
);
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* scripts/region-centroids.test.ts — validation for the committed
|
||||
* `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`).
|
||||
*
|
||||
* The manifest maps each region key to the anchor point a map marker (v0.27,
|
||||
* #44) is placed at: the centroid of the region's largest-area part, plus a
|
||||
* `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the
|
||||
* engine uses to scatter multiple markers of one region around the centroid.
|
||||
* It is a load-bearing data artifact for the marker placement path — this test
|
||||
* pins its contract.
|
||||
*
|
||||
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||||
* and computes bbox checks by iterating coordinates directly (no `@turf`/
|
||||
* geojson types, which would pull `@types/geojson` into the tsc program and
|
||||
* break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of
|
||||
* `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf
|
||||
* `.mjs`) builder and compares bytes, matching that the builder is idempotent.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
function readJson(rel: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||
}
|
||||
|
||||
interface CentroidEntry {
|
||||
lat: number;
|
||||
lon: number;
|
||||
spread: number;
|
||||
}
|
||||
|
||||
/** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */
|
||||
function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> {
|
||||
const c = geometry?.coordinates as
|
||||
| Array<Array<[number, number]>>
|
||||
| Array<Array<Array<[number, number]>>>
|
||||
| undefined;
|
||||
if (!c) return [];
|
||||
if (geometry?.type === 'Polygon') {
|
||||
const rings = c as Array<Array<[number, number]>>;
|
||||
const out: Array<[number, number]> = [];
|
||||
for (const ring of rings) for (const p of ring) out.push(p);
|
||||
return out;
|
||||
}
|
||||
if (geometry?.type === 'MultiPolygon') {
|
||||
const polys = c as Array<Array<Array<[number, number]>>>;
|
||||
const out: Array<[number, number]> = [];
|
||||
for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p);
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */
|
||||
function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] {
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const [x, y] of allCoords(geometry)) {
|
||||
if (x < minLon) minLon = x;
|
||||
if (y < minLat) minLat = y;
|
||||
if (x > maxLon) maxLon = x;
|
||||
if (y > maxLat) maxLat = y;
|
||||
}
|
||||
return [minLon, minLat, maxLon, maxLat];
|
||||
}
|
||||
|
||||
describe('region-centroids.json', () => {
|
||||
const centroids = readJson('public/region-centroids.json') as Record<string, CentroidEntry>;
|
||||
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||
const geo = readJson('public/regions.geo.json') as {
|
||||
features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>;
|
||||
};
|
||||
const keys = Object.values(manifest);
|
||||
|
||||
// featureId -> feature, then regionKey -> feature (via the key manifest).
|
||||
const featureByKey = new Map<string, { geometry?: { type?: string; coordinates?: unknown } }>();
|
||||
for (const f of geo.features ?? []) {
|
||||
const key = manifest[String(f.id)];
|
||||
if (key) featureByKey.set(key, f);
|
||||
}
|
||||
|
||||
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||
expect(new Set(Object.keys(centroids))).toEqual(new Set(keys));
|
||||
expect(Object.keys(centroids)).toHaveLength(1800);
|
||||
});
|
||||
|
||||
it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => {
|
||||
for (const [k, v] of Object.entries(centroids)) {
|
||||
expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true);
|
||||
expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true);
|
||||
expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true);
|
||||
expect(v.spread >= 0, `negative spread for ${k}`).toBe(true);
|
||||
expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true);
|
||||
expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('each centroid lies inside its region feature bounding box', () => {
|
||||
// The recorded point is the centroid of the LARGEST part, which is a subset
|
||||
// of the whole feature, so it must fall inside the feature's overall bbox.
|
||||
let checked = 0;
|
||||
for (const [key, c] of Object.entries(centroids)) {
|
||||
const feat = featureByKey.get(key);
|
||||
if (!feat?.geometry) continue;
|
||||
const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry);
|
||||
const eps = 1e-4; // allow for the 6-dp rounding in the builder
|
||||
expect(
|
||||
c.lat >= minLat - eps && c.lat <= maxLat + eps,
|
||||
`lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
c.lon >= minLon - eps && c.lon <= maxLon + eps,
|
||||
`lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`,
|
||||
).toBe(true);
|
||||
checked++;
|
||||
}
|
||||
expect(checked).toBe(1800);
|
||||
});
|
||||
|
||||
it('spot-checks a couple of well-known anchors land in the right place', () => {
|
||||
// Region keys are sub-national (states/provinces), so we spot-check a few
|
||||
// whose geography is unambiguous. A gross error in the centroid method
|
||||
// (e.g. averaging across all parts of a large multi-part region, or a
|
||||
// lon/lat swap) would land one of these far off.
|
||||
const near = (k: string, lat: number, lon: number, tol: number) => {
|
||||
const c = centroids[k];
|
||||
expect(c, `missing centroid for ${k}`).toBeDefined();
|
||||
expect(Math.abs(c!.lat - lat)).toBeLessThan(tol);
|
||||
expect(Math.abs(c!.lon - lon)).toBeLessThan(tol);
|
||||
};
|
||||
near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France
|
||||
near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California
|
||||
near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province)
|
||||
near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil
|
||||
});
|
||||
|
||||
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||
const file = path.join(root, 'public', 'region-centroids.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
// The builder is cwd-independent (it resolves the repo root from its own
|
||||
// location) and idempotent: same input files + pure turf + fixed rounding
|
||||
// + stable insertion order → identical bytes.
|
||||
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const after = readFileSync(file, 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* scripts/region-geo.test.ts — validation for the committed
|
||||
* `public/region-geo.json` (built by `scripts/build-region-geo.mjs`).
|
||||
*
|
||||
* The manifest maps each region key to its { area (km²), population } — the
|
||||
* weighting source for the per-region stats model (v0.28, #46): a nation's
|
||||
* four 0–100 stats are derived as the area×population-weighted median of the
|
||||
* stats of the regions it owns. It is a load-bearing data artifact — this
|
||||
* test pins its contract.
|
||||
*
|
||||
* The `area` is the turf area of the region's final geometry (km², 2 dp); the
|
||||
* `population` is the country's Natural-Earth admin-0 `POP_EST` (from the
|
||||
* committed `public/ne-admin0-pop.json`) distributed across its sub-regions
|
||||
* proportional to area via largest-remainder, so every country's sub-regions
|
||||
* sum EXACTLY to the country's total (and the global total is the sum over the
|
||||
* 177 repo countries). This is a dependency-free test: it reads the JSON with
|
||||
* plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull
|
||||
* `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the
|
||||
* v0.17 note). The determinism test re-runs the (turf `.mjs`) builder and
|
||||
* compares bytes, matching that the builder is idempotent.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
function readJson(rel: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||
}
|
||||
|
||||
interface GeoEntry {
|
||||
area: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
// The 177 repo countries' admin-0 population totals (the committed derived
|
||||
// source). Only the countries actually present in the region dataset appear.
|
||||
const admin0 = readJson('public/ne-admin0-pop.json') as Record<string, number>;
|
||||
// feature id -> region key
|
||||
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||
// feature id -> { name, parent (ISO_A3) }
|
||||
const regGeo = readJson('public/regions.geo.json') as {
|
||||
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||
};
|
||||
|
||||
// region key -> parent ISO_A3 (via the key manifest + the geo feature parent).
|
||||
const keyToParent: Record<string, string> = {};
|
||||
for (const f of regGeo.features ?? []) {
|
||||
const key = manifest[String(f.id)];
|
||||
const parent = f.properties?.parent;
|
||||
if (key && parent) keyToParent[key] = parent;
|
||||
}
|
||||
|
||||
const geo = readJson('public/region-geo.json') as Record<string, GeoEntry>;
|
||||
const keys = Object.values(manifest);
|
||||
|
||||
describe('region-geo.json', () => {
|
||||
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||
expect(new Set(Object.keys(geo))).toEqual(new Set(keys));
|
||||
expect(Object.keys(geo)).toHaveLength(1800);
|
||||
});
|
||||
|
||||
it('is keyed in sorted (deterministic) order', () => {
|
||||
const ks = Object.keys(geo);
|
||||
expect(ks).toEqual([...ks].sort());
|
||||
});
|
||||
|
||||
it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => {
|
||||
for (const [k, v] of Object.entries(geo)) {
|
||||
expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true);
|
||||
expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true);
|
||||
expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true);
|
||||
// area is recorded to at most 2 decimal places (km²)
|
||||
expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true);
|
||||
expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true);
|
||||
expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => {
|
||||
// The distribution preserved each country's total exactly, so the grand
|
||||
// total over all sub-regions is the sum over the repo countries only.
|
||||
const total = Object.values(geo).reduce((s, v) => s + v.population, 0);
|
||||
expect(total).toBe(7654092021);
|
||||
});
|
||||
|
||||
it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => {
|
||||
// For every parent country present in the region dataset, the sum of its
|
||||
// sub-regions' populations must equal that country's admin-0 POP_EST
|
||||
// EXACTLY — this is the invariant the largest-remainder distribution
|
||||
// guarantees (no population is created or lost by the split).
|
||||
const byParent: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(geo)) {
|
||||
const p = keyToParent[k];
|
||||
if (!p) throw new Error(`no parent for region key ${k}`);
|
||||
byParent[p] = (byParent[p] ?? 0) + v.population;
|
||||
}
|
||||
expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries
|
||||
for (const [p, sum] of Object.entries(byParent)) {
|
||||
expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined();
|
||||
expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]);
|
||||
}
|
||||
});
|
||||
|
||||
it('spot-checks well-known regions for exact area + population', () => {
|
||||
const is = (k: string, area: number, population: number) => {
|
||||
const v = geo[k];
|
||||
expect(v, `missing geo for ${k}`).toBeDefined();
|
||||
expect(v!.area, `area for ${k}`).toBe(area);
|
||||
expect(v!.population, `population for ${k}`).toBe(population);
|
||||
};
|
||||
is('california-kikh', 413329.21, 14184195); // US-CA
|
||||
is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core
|
||||
is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41)
|
||||
is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41)
|
||||
is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31)
|
||||
is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31)
|
||||
is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain)
|
||||
});
|
||||
|
||||
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||
const file = path.join(root, 'public', 'region-geo.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
// The builder is cwd-independent (it resolves the repo root from its own
|
||||
// location) and idempotent: same input files (regions.geo.json +
|
||||
// region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding
|
||||
// + stable (sorted) key order → identical bytes.
|
||||
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const after = readFileSync(file, 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, …)`,
|
||||
);
|
||||
@@ -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}`);
|
||||
@@ -25,6 +25,12 @@ function makeState(id: string): GameState {
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// (v0.28, #46) a v0.28+ save carries per-region stats; the fake 'GBR'
|
||||
// region here holds a copy of the uk's (single-region) stats.
|
||||
regionStats: {
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +103,50 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.markers).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a legacy row saved before the regionStats field existed loads with it seeded per region and stats recomputed', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { regionStats?: Record<string, unknown> }).regionStats; // a pre-v0.28 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
// each owned region is seeded with a copy of its owner's stats…
|
||||
expect(loaded!.regionStats).toEqual({
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
});
|
||||
// …and a single-region nation's derived stats equal the seeded ones
|
||||
// (the weighted median of identical values is that value).
|
||||
expect(loaded!.nations.uk.stats).toEqual({ military: 80, economy: 70, stability: 60, population: 50 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a v0.28+ row (regionStats present) loads UNCHANGED — no recompute on load', () => {
|
||||
const db = openDb(':memory:');
|
||||
const s = makeState('current');
|
||||
// Diverge one region's stats from the nation level to prove load does not
|
||||
// recompute (a v0.28+ save keeps its stored derived stats exactly).
|
||||
s.regionStats = { GBR: { military: 99, economy: 70, stability: 60, population: 50 } };
|
||||
s.nations = { uk: { ...s.nations.uk, stats: { military: 42, economy: 70, stability: 60, population: 50 } } };
|
||||
db.save(s);
|
||||
const loaded = db.load('current');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.regionStats).toEqual({ GBR: { military: 99, economy: 70, stability: 60, population: 50 } });
|
||||
// the nation's stats are NOT recomputed from the region on load (they stay 42)
|
||||
expect(loaded!.nations.uk.stats.military).toBe(42);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { migrateRegionStats } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
export interface Db {
|
||||
@@ -39,13 +40,22 @@ export function openDb(filePath: string): Db {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const state = JSON.parse(row.data) as GameState;
|
||||
let state = JSON.parse(row.data) as GameState;
|
||||
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||
if (state.unowned === undefined) state.unowned = [];
|
||||
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||
// has none awaiting confirm/reject.
|
||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||
if (state.markers === undefined) state.markers = [];
|
||||
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||
// ONCE at load time (and never re-migrated afterwards — the field is
|
||||
// then present): each owned region is seeded with a copy of its owner's
|
||||
// nation-level stats and the nations' stats are recomputed as the
|
||||
// (uniform-weight) area×population-weighted median, so no data is lost
|
||||
// and a single-region nation keeps exactly its old stats.
|
||||
state = migrateRegionStats(state);
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+1044
-23
File diff suppressed because it is too large
Load Diff
+514
-28
@@ -7,21 +7,32 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
MapMarker,
|
||||
Nation,
|
||||
NationStats,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RegionStats,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
canConquer,
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
makeEvent,
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
nationForPrompt,
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
type RegionStatChange,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
@@ -179,6 +190,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ],',
|
||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||
' "regionChanges": [{"region":"<region key or name>","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":["<region key>"],"lose":["<region key>"]}],',
|
||||
' "unowned": ["<region key>"],',
|
||||
@@ -187,12 +199,19 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
|
||||
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
|
||||
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
|
||||
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and region keys that exist in the provided state.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
||||
'- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).',
|
||||
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
|
||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
@@ -200,6 +219,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- 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.',
|
||||
'- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.',
|
||||
...territoryRules,
|
||||
'- If a field is not relevant this turn, set it to an empty array.',
|
||||
].join('\n');
|
||||
@@ -232,6 +252,14 @@ export function buildUserPrompt(
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
noMansLand?: string[],
|
||||
/**
|
||||
* (v0.27, #44) The game's current map markers, as the model sees them:
|
||||
* stable id, type, name, region key and current owner. Emitted verbatim
|
||||
* (even when empty) so the model can reference a marker's id in
|
||||
* `TurnResult.markers.remove` or leave an existing marker untouched.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
markers?: MarkerInfo[],
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -245,6 +273,76 @@ export function buildUserPrompt(
|
||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||
|
||||
// (v0.29, #46 PR 2) The player's own regions' current per-region stats, so the
|
||||
// model can reason about — and target via `regionChanges` — the player's
|
||||
// individual regions. Bounded to the player's territory (not the whole
|
||||
// world's 1,800 regions) to keep the prompt small. Always emitted: {} when
|
||||
// the player has no region with a stats entry yet.
|
||||
const playerRegionStats: Record<string, RegionStats> = {};
|
||||
if (state.regionStats) {
|
||||
for (const key of player?.territory ?? []) {
|
||||
const rs = state.regionStats[key];
|
||||
if (rs) playerRegionStats[key] = rs;
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
|
||||
// so the model — and, via the same data, the player — sees the hard gate
|
||||
// before it fires: for each frontier neighbor, compare the player's derived
|
||||
// military to the neighbor's and state whether a direct invasion would be
|
||||
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
|
||||
// emitted only when a non-empty frontier is present, and the rest of the
|
||||
// payload is left untouched.
|
||||
let frontierMilitary: string[] | undefined;
|
||||
if (frontier !== undefined && frontier.length > 0) {
|
||||
const playerMil = player?.stats.military ?? 0;
|
||||
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
|
||||
for (const n of Object.values(state.nations)) {
|
||||
for (const k of n.territory) ownerByKey.set(k, n.id);
|
||||
}
|
||||
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
|
||||
for (const k of frontier) {
|
||||
const ownerId = ownerByKey.get(k);
|
||||
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
|
||||
const owner = state.nations[ownerId];
|
||||
if (!owner) continue;
|
||||
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
|
||||
}
|
||||
if (byNeighbor.size > 0) {
|
||||
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
|
||||
const o = state.nations[ownerId];
|
||||
const wins = canConquer(playerMil, mil);
|
||||
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
|
||||
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
|
||||
}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||||
// AND that currently have a "gathering" marker on one of their regions are
|
||||
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||||
// seizure) — the model can act on them this turn. One line per matching
|
||||
// nation, emitted only when there is at least one; the rest of the payload
|
||||
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||
let takeoverRisks: string[] | undefined;
|
||||
if (markers !== undefined && markers.length > 0) {
|
||||
const risks = new Set<string>();
|
||||
for (const m of markers) {
|
||||
if (m.type !== 'gathering') continue;
|
||||
const ownerId = m.owner;
|
||||
if (!ownerId) continue;
|
||||
const n = state.nations[ownerId];
|
||||
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||
}
|
||||
if (risks.size > 0) {
|
||||
takeoverRisks = [...risks].map((id) => {
|
||||
const n = state.nations[id];
|
||||
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
@@ -266,10 +364,14 @@ export function buildUserPrompt(
|
||||
playerActions,
|
||||
pending: items,
|
||||
roundInstruction,
|
||||
playerRegionStats,
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -309,8 +411,13 @@ function extractBalancedObject(text: string): string | null {
|
||||
/**
|
||||
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
||||
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
||||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` is threaded to
|
||||
* `validateTurnResult` to resolve `regionChanges` refs to region keys.
|
||||
*/
|
||||
export function parseTurnResult(raw: string): TurnResult | null {
|
||||
export function parseTurnResult(
|
||||
raw: string,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): TurnResult | null {
|
||||
const cleaned = stripFence(raw);
|
||||
const jsonText = extractBalancedObject(cleaned);
|
||||
if (!jsonText) return null;
|
||||
@@ -320,7 +427,7 @@ export function parseTurnResult(raw: string): TurnResult | null {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return validateTurnResult(obj);
|
||||
return validateTurnResult(obj, regionKeyResolver);
|
||||
}
|
||||
|
||||
function str(s: unknown, max = 0): string | null {
|
||||
@@ -381,18 +488,28 @@ export function validateNationOps(raw: unknown): NationOp[] {
|
||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||
* only when the input is not even a plain object.
|
||||
*
|
||||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` resolves each
|
||||
* `regionChanges` ref (key or name) to a concrete region key, dropping
|
||||
* unknown/ambiguous refs; without it the refs are kept as-is (shape-only), so
|
||||
* callers that build a TurnResult by hand (tests) need not supply it.
|
||||
*/
|
||||
export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
export function validateTurnResult(
|
||||
input: unknown,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): TurnResult | null {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
||||
const o = input as Record<string, unknown>;
|
||||
const result: TurnResult = {
|
||||
date: typeof o.date === 'string' ? o.date : '',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
if (Array.isArray(o.events)) {
|
||||
@@ -482,9 +599,112 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
result.markers = validateMarkerOps(o.markers);
|
||||
|
||||
// (v0.29, #46 PR 2) Per-region stat changes: each targets a single region's
|
||||
// stats (by key or name). Resolved to a key here when a resolver is
|
||||
// supplied; applied by applyTurnResult after the nation-wide stateChanges
|
||||
// and before the derived-stat recompute.
|
||||
result.regionChanges = validateRegionChanges(o.regionChanges, regionKeyResolver);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.27, #44) Validate the raw `markers` object of a turn into a safe
|
||||
* `{ add, remove }` shape. Defensive like the rest of the parser — never
|
||||
* throws. `add`: each spec needs a known marker type, a non-empty `name`
|
||||
* (capped at 120 chars) and a non-empty `regionKey` (capped at 120 chars);
|
||||
* specs whose derived id collides with an earlier one in the same batch
|
||||
* collapse to the first (re-adding is idempotent). `remove`: deduped,
|
||||
* non-empty marker ids (capped at 120 chars).
|
||||
*/
|
||||
export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: string[] } {
|
||||
const out: { add: MarkerSpec[]; remove: string[] } = { add: [], remove: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.add)) {
|
||||
const seen = new Set<string>();
|
||||
for (const e of o.add) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const s = e as Record<string, unknown>;
|
||||
if (!isMarkerType(s.type)) continue;
|
||||
const name = str(s.name, 120);
|
||||
const regionKey = str(s.regionKey, 120);
|
||||
if (!name || !regionKey) continue;
|
||||
const id = markerId(s.type, name, regionKey);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.add.push({ type: s.type, name, regionKey });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(o.remove)) {
|
||||
for (const k of o.remove) {
|
||||
const s = typeof k === 'string' ? k.trim() : '';
|
||||
if (s && !out.remove.includes(s)) out.remove.push(s.slice(0, 120));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Validate the raw `regionChanges` array of a turn into a
|
||||
* safe {@link RegionStatChange} list — the fine-grained, per-region stat lever
|
||||
* that complements the nation-wide `stateChanges`. Defensive like the rest of
|
||||
* the parser — never throws. Each entry needs a non-empty `region` (the
|
||||
* region's stable key, preferred, or its human-readable name — capped at 120
|
||||
* chars), a known stat, a finite numeric `value`, and an op of `set` or `add`.
|
||||
*
|
||||
* When `regionKeyResolver` is provided, each `region` ref is resolved to a
|
||||
* concrete region key (key first, then a unique case-insensitive name match —
|
||||
* see {@link resolveRegionKey}) and the resolved key is stored; a ref that
|
||||
* resolves to `null` (unknown or ambiguous) is dropped, exactly like an
|
||||
* unresolvable nation ref. When the resolver is absent (pure unit tests), the
|
||||
* ref is kept as-is (shape-only validation) — a name that is not a key simply
|
||||
* no-ops later in `applyTurnResult` (its `regionStats` lookup misses). `set`
|
||||
* values are clamped to 0–100 here; `add` deltas are clamped against the
|
||||
* current value at apply time (they may legitimately exceed 100 in the raw
|
||||
* input, since they are added to an existing 0–100 value).
|
||||
*/
|
||||
export function validateRegionChanges(
|
||||
raw: unknown,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): RegionStatChange[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: RegionStatChange[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const c = e as Record<string, unknown>;
|
||||
const region = str(c.region, 120);
|
||||
const stat = str(c.stat);
|
||||
if (!region || !stat || !VALID_STATS.has(stat)) continue;
|
||||
if (typeof c.value !== 'number' || !Number.isFinite(c.value)) continue;
|
||||
if (c.op !== 'set' && c.op !== 'add') continue;
|
||||
// Resolve the ref to a key when a resolver is available; drop it when the
|
||||
// resolver says the ref is unknown or ambiguous (never throw). Without a
|
||||
// resolver the ref is kept as-is (a name that is not a key no-ops later in
|
||||
// applyTurnResult, whose regionStats lookup simply misses it).
|
||||
let finalRegion: string;
|
||||
if (regionKeyResolver) {
|
||||
const resolved = regionKeyResolver(region);
|
||||
if (resolved === null) continue; // unknown or ambiguous → drop
|
||||
finalRegion = resolved;
|
||||
} else {
|
||||
finalRegion = region;
|
||||
}
|
||||
out.push({
|
||||
region: finalRegion,
|
||||
stat: stat as StatKey,
|
||||
op: c.op,
|
||||
value: c.op === 'set' ? clampStat(c.value) : c.value,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
@@ -497,8 +717,13 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
result.events.length > 0 ||
|
||||
result.stateChanges.length > 0 ||
|
||||
// (v0.29, #46 PR 2) a per-region stat change is a real world change.
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -516,15 +741,44 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
|
||||
/**
|
||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||
* events, apply stat changes (clamped), symmetric relations, and territory
|
||||
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
|
||||
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
|
||||
* them — see the doc comment on `pendingNationOps` for why that list
|
||||
* deliberately survives across turns.
|
||||
* events, apply stat changes (clamped), symmetric relations, territory moves
|
||||
* (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27
|
||||
* #44). It also appends any proposed nation-ops to the pending list (v0.23
|
||||
* #16 P4a) without applying them — see the doc comment on `pendingNationOps`
|
||||
* for why that list deliberately survives across turns.
|
||||
*
|
||||
* `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid
|
||||
* (`{ lat, lon, spread }`, from public/region-centroids.json via
|
||||
* server/regions.ts). New markers are positioned at that centroid plus a small
|
||||
* deterministic offset; a spec whose key has no known centroid is dropped.
|
||||
* Without it no marker can be placed (all adds are dropped), which keeps the
|
||||
* engine pure and lets tests inject their own data.
|
||||
*
|
||||
* `geoOf` (v0.28, #46) resolves a region key to its area + population
|
||||
* (from public/region-geo.json via server/regions.ts), used to weight the
|
||||
* end-of-turn recompute of each nation's derived stats as the
|
||||
* area×population-weighted median of its regions' stats. Without it the
|
||||
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||
* region-consistent, just unweighted. The engine stays pure either way.
|
||||
*
|
||||
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
|
||||
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||||
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||||
* the raw key. Pure either way.
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||
export function applyTurnResult(
|
||||
state: GameState,
|
||||
result: TurnResult,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
|
||||
// field starts empty (the turn then re-inits claimed regions as it goes).
|
||||
next.regionStats = next.regionStats ?? {};
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
@@ -533,11 +787,6 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
for (const ev of result.events) {
|
||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||
}
|
||||
for (const c of result.stateChanges) {
|
||||
const n = next.nations[c.nation];
|
||||
if (!n) continue;
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
for (const r of result.relations) {
|
||||
const a = next.nations[r.a];
|
||||
const b = next.nations[r.b];
|
||||
@@ -587,6 +836,10 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||||
unowned.add(key);
|
||||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
||||
// (v0.28, #46) a released region leaves no-man's land ownerless — its
|
||||
// per-region stats leave the table with it (no man's land carries none;
|
||||
// a later re-claim re-inits the entry from the gainer, see below).
|
||||
delete next.regionStats[key];
|
||||
}
|
||||
|
||||
// (2) Claims against the post-release ownership snapshot.
|
||||
@@ -627,15 +880,165 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||||
// different sole owner) only proceeds when the attacker's derived military
|
||||
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||||
// re-claim of an unowned key (owner === undefined) is never gated. On
|
||||
// repel, the key stays with its owner, a "repelled" event is emitted (the
|
||||
// engine's own narrative — the model never sees it as an input), and the
|
||||
// gainer is untouched; the engine never throws.
|
||||
if (
|
||||
owner !== undefined &&
|
||||
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
|
||||
) {
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: gainer,
|
||||
type: 'military',
|
||||
title: 'Invasion repelled',
|
||||
text: `${next.nations[gainer].name}'s invasion of ${
|
||||
regionNameOf?.(key) ?? key
|
||||
} was repelled by ${next.nations[owner].name}.`,
|
||||
outcome: 'repelled',
|
||||
},
|
||||
actingTurn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
const i = n.territory.indexOf(key);
|
||||
if (i !== -1) n.territory.splice(i, 1);
|
||||
}
|
||||
next.nations[gainer].territory.push(key);
|
||||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
||||
// (v0.28, #46) a transferred region keeps its own per-region stats (the
|
||||
// conquest is what makes a nation's regions diverge); only a re-claimed
|
||||
// region that has no entry left (it was released earlier this turn) is
|
||||
// re-inited as a copy of the gainer's current stats, so the recompute
|
||||
// below never sees a hole in the table.
|
||||
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
|
||||
}
|
||||
|
||||
next.unowned = [...unowned];
|
||||
|
||||
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
|
||||
// the regions, then every nation's stats recomputed as the derived
|
||||
// area×population-weighted median of its regions ---
|
||||
//
|
||||
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
|
||||
// applying to EVERY region the nation owns: each owned region's copy of
|
||||
// that stat gets the same `set`/`add` (clamped to 0–100), and the
|
||||
// nation-level stat is updated too (it is kept as-is for nations the
|
||||
// recompute below does not touch — see (b) — and overwritten for the
|
||||
// rest, where the regions are the source of truth).
|
||||
//
|
||||
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
|
||||
// median of its owned regions' stats (via deriveNationStats, weighted
|
||||
// by area×population when `geoOf` is available, unweighted otherwise).
|
||||
// Two guards keep degenerate states sane: a nation with NO regions
|
||||
// keeps its stats untouched (it has no regions to aggregate), and a
|
||||
// nation none of whose regions has a stats entry keeps its stats
|
||||
// too (deriveNationStats would return zeros, which would be wrong).
|
||||
// This recompute is what makes a conquered region's foreign stats
|
||||
// blend into the conqueror's aggregate and keeps the derived stats in
|
||||
// lockstep with the per-region table across the turn's changes.
|
||||
for (const c of result.stateChanges) {
|
||||
const n = next.nations[c.nation];
|
||||
if (!n) continue;
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
|
||||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||
}
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
// (v0.29, #46 PR 2) Fine-grained, per-region stat changes: each targets ONE
|
||||
// region's own stats entry (by its resolved region key). Applied AFTER the
|
||||
// nation-wide stateChanges (so a region-level change overrides the
|
||||
// nation-level one for that single region) and BEFORE the weighted-median
|
||||
// recompute below, which propagates every region's final value up into its
|
||||
// owning nation's derived `stats`. A region with no stats entry (unowned /
|
||||
// no man's land) is a no-op — there is nothing to change.
|
||||
for (const c of result.regionChanges) {
|
||||
const rs = next.regionStats[c.region];
|
||||
if (!rs) continue; // no entry (unowned) → no-op
|
||||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
// post-claim ownership, in this order:
|
||||
// (1) REMOVE: a marker whose id is in result.markers.remove is dropped
|
||||
// (a listed-but-absent id is a no-op).
|
||||
// (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to
|
||||
// its region's current owner — a captured region follows the new
|
||||
// owner, a region released to no man's land (or a key with 0 or 2+
|
||||
// owners) becomes `null`. A marker always follows its region.
|
||||
// (3) ADD: each new spec (in order) is placed at its region's centroid
|
||||
// (injected via centroidOf) plus a small deterministic per-axis
|
||||
// offset, owned by the region's post-turn owner, and stamped with
|
||||
// the acting turn. Dropped (never throws): a region key that is not
|
||||
// in validKeys or has no known centroid, an id that already exists
|
||||
// (a re-add is idempotent — the existing marker stays), and any adds
|
||||
// beyond MAX_MARKERS (the later ones).
|
||||
{
|
||||
const finalOwner = new Map<string, string | null>();
|
||||
{
|
||||
const owners = new Map<string, string[]>();
|
||||
for (const n of Object.values(next.nations)) {
|
||||
for (const key of n.territory) {
|
||||
if (!valid(key)) continue;
|
||||
const list = owners.get(key);
|
||||
if (list) list.push(n.id);
|
||||
else owners.set(key, [n.id]);
|
||||
}
|
||||
}
|
||||
for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null);
|
||||
}
|
||||
const removeIds = new Set(result.markers.remove);
|
||||
const markers: MapMarker[] = [];
|
||||
for (const m of next.markers ?? []) {
|
||||
if (removeIds.has(m.id)) continue;
|
||||
markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null });
|
||||
}
|
||||
// Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit
|
||||
// fraction of the marker id (stable across runs and platforms), so
|
||||
// markers of one region scatter naturally instead of stacking.
|
||||
const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => {
|
||||
const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff;
|
||||
return (frac * 2 - 1) * (spread / 2);
|
||||
};
|
||||
const round6 = (v: number): number => Math.round(v * 1e6) / 1e6;
|
||||
for (const spec of result.markers.add) {
|
||||
const id = markerId(spec.type, spec.name, spec.regionKey);
|
||||
if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing
|
||||
if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess
|
||||
if (!valid(spec.regionKey)) continue; // unknown region key
|
||||
const c = centroidOf?.(spec.regionKey);
|
||||
if (!c) continue; // no centroid data for this key
|
||||
markers.push({
|
||||
id,
|
||||
type: spec.type,
|
||||
name: spec.name,
|
||||
regionKey: spec.regionKey,
|
||||
lat: round6(c.lat + jitter(id, 'lat', c.spread)),
|
||||
lon: round6(c.lon + jitter(id, 'lon', c.spread)),
|
||||
ownerNationId: finalOwner.get(spec.regionKey) ?? null,
|
||||
placedTurn: actingTurn,
|
||||
});
|
||||
}
|
||||
next.markers = markers;
|
||||
}
|
||||
|
||||
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
|
||||
// are appended to the pending list, which (unlike `pending`) SURVIVES
|
||||
// across turns until the player confirms or rejects each one. `next` is a
|
||||
@@ -711,6 +1114,37 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
|
||||
* `region` — the LLM may write either the region's stable **key** or its
|
||||
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||||
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
|
||||
* when the reference is empty, unknown, or ambiguous (two or more regions
|
||||
* share that name) — every caller treats `null` as "drop this change" and
|
||||
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
|
||||
* the set of all region keys and `nameOf` maps a key to its human name (both
|
||||
* supplied by server/regions.ts).
|
||||
*/
|
||||
export function resolveRegionKey(
|
||||
ref: string,
|
||||
validKeys: ReadonlySet<string>,
|
||||
nameOf: (key: string) => string | undefined,
|
||||
): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (validKeys.has(r)) return r; // exact key first
|
||||
const lower = r.toLowerCase();
|
||||
let match: string | null = null;
|
||||
for (const k of validKeys) {
|
||||
const name = nameOf(k);
|
||||
if (name && name.trim().toLowerCase() === lower) {
|
||||
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||||
match = k;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused nation id for a newly created nation (split
|
||||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||
@@ -965,15 +1399,56 @@ export async function runTurn(opts: {
|
||||
* switches the turn onto the getNeighbours tool path.
|
||||
*/
|
||||
getNeighbours?: (keys: string[]) => string[];
|
||||
/**
|
||||
* (v0.27, #44) Region-key -> precomputed centroid resolver (from
|
||||
* public/region-centroids.json). Present, the turn's new markers can be
|
||||
* placed at their region's centroid; absent, marker adds are dropped and
|
||||
* the existing markers' owners still get recomputed.
|
||||
*/
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
||||
/**
|
||||
* (v0.28, #46) Region-key -> area/population resolver (from
|
||||
* public/region-geo.json). Present, the end-of-turn recompute of each
|
||||
* nation's derived stats weights each owned region by area×population;
|
||||
* absent, the recompute degrades to an unweighted (uniform) median —
|
||||
* still region-consistent, just unweighted.
|
||||
*/
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Region ref resolver: resolves a `regionChanges` entry's
|
||||
* `region` (a region key, preferred, or a human-readable name) to a concrete
|
||||
* region key, or `null` when the ref is unknown or ambiguous (two or more
|
||||
* regions share the name) — the engine drops such entries. Present, the
|
||||
* model may name a region by its name and it is resolved before apply;
|
||||
* absent, refs are kept as-is (a name that is not a key simply no-ops in
|
||||
* applyTurnResult's regionStats lookup).
|
||||
*/
|
||||
regionKeyResolver?: (ref: string) => string | null;
|
||||
/**
|
||||
* (v0.30, #50) Region-key -> human display name resolver (the geo
|
||||
* `properties.name`, from server/regions.ts). Present, the engine's
|
||||
* "invasion repelled" event names the region by its display name; absent,
|
||||
* it falls back to the raw region key. Pure either way.
|
||||
*/
|
||||
regionNameOf?: (key: string) => string | undefined;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
||||
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
|
||||
// list on a fresh game) so it knows which unowned regions can be re-claimed.
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
|
||||
// (v0.27, #44) likewise the current map markers, so it can reference their
|
||||
// stable ids in `markers.remove` (or leave them untouched).
|
||||
const markers = (state.markers ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
name: m.name,
|
||||
regionKey: m.regionKey,
|
||||
owner: m.ownerNationId,
|
||||
}));
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
@@ -995,18 +1470,24 @@ export async function runTurn(opts: {
|
||||
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
||||
};
|
||||
|
||||
let result = parseTurnResult(await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
]));
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(await call([
|
||||
let result = parseTurnResult(
|
||||
await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
]));
|
||||
]),
|
||||
regionKeyResolver,
|
||||
);
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(
|
||||
await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
]),
|
||||
regionKeyResolver,
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
@@ -1014,14 +1495,19 @@ export async function runTurn(opts: {
|
||||
date: addDays(state.date, 2),
|
||||
events: [genericUnclearEvent(state)],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result, validRegionKeys), result };
|
||||
return {
|
||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
+33
-3
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||||
import type {
|
||||
AddActionRequest,
|
||||
AddDiplomacyRequest,
|
||||
@@ -20,9 +20,9 @@ import type {
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
@@ -134,11 +134,20 @@ app.post('/api/games', (req: Request, res) => {
|
||||
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||
// shrinks as the player confirms or rejects them.
|
||||
pendingNationOps: [],
|
||||
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||
markers: [],
|
||||
// (v0.28, #46) Per-region stats are seeded below, once territories hold
|
||||
// real region keys — each region starts as a copy of its owner's initial
|
||||
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||
regionStats: {},
|
||||
};
|
||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
|
||||
// region key gets its owner's initial stats (see seedRegionStats).
|
||||
state.regionStats = seedRegionStats(state.nations);
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
});
|
||||
@@ -288,6 +297,27 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||
// tool and the player-frontier prompt field (both switch off without it).
|
||||
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
||||
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
|
||||
// resolver so a turn can place new map markers at their region's
|
||||
// centroid (without it, marker adds are dropped; existing markers are
|
||||
// still maintained).
|
||||
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
||||
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
|
||||
// hand the engine the resolver so the end-of-turn recompute weights each
|
||||
// owned region by area×population (without it the aggregate degrades to
|
||||
// an unweighted median — still region-consistent, just unweighted).
|
||||
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
|
||||
// (v0.29, #46 PR 2) region ref resolver: lets the model name a
|
||||
// regionChanges target by its human-readable name (resolved
|
||||
// case-insensitively against the region-key manifest) or its key;
|
||||
// unknown / ambiguous refs are dropped. Always on — the resolver is
|
||||
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||
// the manifest is absent.
|
||||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||||
// (v0.30, #50) region display name for the engine's "invasion repelled"
|
||||
// event (geo properties.name, else the raw key). Always on — the
|
||||
// resolver is pure and degrades to the key when the manifest is absent.
|
||||
regionNameOf: (k) => regionName(k),
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
|
||||
@@ -49,6 +49,9 @@ const state: GameState = {
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||
regionStats: {},
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationStats,
|
||||
migrateRegionStats,
|
||||
seedRegionStats,
|
||||
weightedMedian,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState, Nation, NationStats } from '../shared/types';
|
||||
|
||||
// --- Test fixtures -----------------------------------------------------------
|
||||
|
||||
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
|
||||
military,
|
||||
economy,
|
||||
stability,
|
||||
population,
|
||||
});
|
||||
|
||||
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
|
||||
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||
}
|
||||
|
||||
function mkState(
|
||||
nations: Record<string, Nation>,
|
||||
regionStats?: Record<string, unknown>,
|
||||
): GameState {
|
||||
const base: GameState = {
|
||||
id: 'g',
|
||||
presetId: 'p',
|
||||
presetTitle: 'P',
|
||||
playerNationId: Object.keys(nations)[0] ?? '',
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
nations,
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats: {} as GameState['regionStats'],
|
||||
};
|
||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||
return base;
|
||||
}
|
||||
|
||||
// --- weightedMedian ----------------------------------------------------------
|
||||
|
||||
describe('weightedMedian', () => {
|
||||
it('returns 0 for an empty input or zero total weight', () => {
|
||||
expect(weightedMedian([], [])).toBe(0);
|
||||
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
|
||||
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
|
||||
});
|
||||
|
||||
it('returns the single value when it is the only weighted input', () => {
|
||||
expect(weightedMedian([42], [7])).toBe(42);
|
||||
});
|
||||
|
||||
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
|
||||
// The invariant seeding + legacy migration rely on: equal values → that value,
|
||||
// regardless of the (arbitrary) weights.
|
||||
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
|
||||
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
|
||||
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
|
||||
// and a constant shift shifts the result by the same constant
|
||||
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
|
||||
});
|
||||
|
||||
it('picks the value at the first point the cumulative weight reaches half the total', () => {
|
||||
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
|
||||
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
|
||||
// an odd middle: the true middle value
|
||||
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
|
||||
// the heavier value dominates
|
||||
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
|
||||
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
|
||||
});
|
||||
|
||||
it('treats non-finite or non-positive weights as excluded (0)', () => {
|
||||
// NaN weight on 10, positive on 20 → only 20 counts
|
||||
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
|
||||
// negative weight on 20, positive on 10 → only 10 counts
|
||||
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// --- deriveNationStats -------------------------------------------------------
|
||||
|
||||
describe('deriveNationStats', () => {
|
||||
const regionStats = {
|
||||
a: S(50, 50, 50, 50),
|
||||
b: S(90, 90, 90, 90),
|
||||
};
|
||||
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||
|
||||
it('weights by area×population: the larger region dominates the aggregate', () => {
|
||||
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
|
||||
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
|
||||
expect(out).toEqual(S(90, 90, 90, 90));
|
||||
});
|
||||
|
||||
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
|
||||
// equal weights 1,1 → the lower (a=50) is reached at half-total first
|
||||
const out = deriveNationStats(['a', 'b'], regionStats);
|
||||
expect(out).toEqual(S(50, 50, 50, 50));
|
||||
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||||
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
|
||||
expect(out2).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('skips a region key that has no stats entry', () => {
|
||||
// b has no entry → only a is counted → a's (50) values
|
||||
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
|
||||
expect(out).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('returns zero stats when none of the owned regions has a stats entry', () => {
|
||||
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
|
||||
expect(out).toEqual(S(0, 0, 0, 0));
|
||||
});
|
||||
|
||||
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
|
||||
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
|
||||
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
|
||||
});
|
||||
|
||||
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||||
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
|
||||
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
|
||||
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
|
||||
expect(out).toEqual(S(90, 90, 90, 90));
|
||||
});
|
||||
});
|
||||
|
||||
// --- seedRegionStats ---------------------------------------------------------
|
||||
|
||||
describe('seedRegionStats', () => {
|
||||
it('copies each owner\'s stats to every region it owns', () => {
|
||||
const nations = {
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
|
||||
};
|
||||
expect(seedRegionStats(nations)).toEqual({
|
||||
k1: S(80, 70, 60, 50),
|
||||
k2: S(80, 70, 60, 50),
|
||||
k3: S(95, 70, 75, 90),
|
||||
});
|
||||
});
|
||||
|
||||
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
|
||||
const nations = {
|
||||
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
|
||||
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
|
||||
};
|
||||
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
|
||||
});
|
||||
|
||||
it('returns an empty table for nations with no territory', () => {
|
||||
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
|
||||
expect(seedRegionStats(nations)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// --- migrateRegionStats ------------------------------------------------------
|
||||
|
||||
describe('migrateRegionStats', () => {
|
||||
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
|
||||
const state = mkState(
|
||||
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
|
||||
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
|
||||
);
|
||||
const out = migrateRegionStats(state);
|
||||
expect(out).toBe(state); // same reference: untouched
|
||||
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
|
||||
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
|
||||
});
|
||||
|
||||
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
|
||||
const state = mkState({
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
|
||||
});
|
||||
// sanity: we built a legacy row
|
||||
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||
|
||||
const out = migrateRegionStats(state, (k) =>
|
||||
k === 'k1' ? { area: 100, population: 1000 }
|
||||
: k === 'k2' ? { area: 1, population: 1 }
|
||||
: k === 'k3' ? { area: 9, population: 9 }
|
||||
: { area: 50, population: 500 });
|
||||
// each owned region is seeded with a copy of its owner's stats…
|
||||
expect(out.regionStats).toEqual({
|
||||
k1: S(80, 70, 60, 50),
|
||||
k2: S(80, 70, 60, 50),
|
||||
k3: S(80, 70, 60, 50),
|
||||
k4: S(95, 70, 75, 90),
|
||||
});
|
||||
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
|
||||
// identical values is that value, regardless of the (uneven) weights.
|
||||
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
|
||||
// the original row is NOT mutated
|
||||
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
|
||||
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
|
||||
});
|
||||
|
||||
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
|
||||
const state = mkState({
|
||||
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
|
||||
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
|
||||
});
|
||||
const out = migrateRegionStats(state);
|
||||
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
|
||||
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
|
||||
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
|
||||
expect('uk' in (out.regionStats ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
|
||||
|
||||
describe('canConquer', () => {
|
||||
it('the default margin constant is 15', () => {
|
||||
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||||
});
|
||||
|
||||
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
|
||||
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
|
||||
expect(canConquer(10, 25)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
|
||||
// attacker 9, defender 25 → gap of 16 > margin → repel
|
||||
expect(canConquer(9, 25)).toBe(false);
|
||||
});
|
||||
|
||||
it('an equal attacker proceeds', () => {
|
||||
expect(canConquer(50, 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker within the margin (gap < margin) proceeds', () => {
|
||||
expect(canConquer(30, 40)).toBe(true); // gap 10
|
||||
expect(canConquer(14, 25)).toBe(true); // gap 11
|
||||
});
|
||||
|
||||
it('an attacker ahead of the defender always proceeds', () => {
|
||||
expect(canConquer(90, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
|
||||
expect(canConquer(50, 50, 0)).toBe(true); // equal
|
||||
expect(canConquer(51, 50, 0)).toBe(true); // ahead
|
||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||
});
|
||||
});
|
||||
+91
-4
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
allTerritoryKeys,
|
||||
centroidOf,
|
||||
expandCountriesToKeys,
|
||||
geoOf,
|
||||
hasAdjacency,
|
||||
hasCentroids,
|
||||
hasGeo,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
neighboursOf,
|
||||
@@ -27,9 +31,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 +55,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)');
|
||||
@@ -88,6 +107,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||
regionStats: {},
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
@@ -118,6 +140,34 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
expect(neighboursOf([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => {
|
||||
expect(hasCentroids()).toBe(true);
|
||||
const ain = centroidOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||
expect(ain!.lon).toBeCloseTo(4.8, 1);
|
||||
expect(ain!.spread).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => {
|
||||
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => {
|
||||
expect(hasGeo()).toBe(true);
|
||||
const ain = geoOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.area).toBeGreaterThanOrEqual(0); // km²
|
||||
expect(ain!.population).toBeGreaterThanOrEqual(0);
|
||||
// exact spot-check values pinned from the committed manifest (Ain, FRA)
|
||||
expect(ain!.area).toBeCloseTo(22557.77, 1); // km²
|
||||
expect(ain!.population).toBe(2304126);
|
||||
});
|
||||
|
||||
it('(v0.28 #46) geoOf returns undefined for an unknown key', () => {
|
||||
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('graceful degradation with no region data', () => {
|
||||
beforeEach(() => resetRegions());
|
||||
|
||||
@@ -131,6 +181,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
// and no adjacency: the LLM tool / frontier field switch off
|
||||
expect(hasAdjacency()).toBe(false);
|
||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||
// and no centroids: the marker-placement path degrades to a no-op
|
||||
expect(hasCentroids()).toBe(false);
|
||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-geo: the per-region stats weighting degrades to uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,13 +222,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 []', () => {
|
||||
@@ -182,6 +238,37 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasAdjacency()).toBe(false);
|
||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||
expect(hasCentroids()).toBe(false);
|
||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
|
||||
expect(hasGeo()).toBe(false);
|
||||
expect(geoOf('ain-lqtt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||
copyFileSync(
|
||||
path.join(realRoot, 'public', 'region-centroids.json'),
|
||||
path.join(prodRoot, 'dist', 'region-centroids.json'),
|
||||
);
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasCentroids()).toBe(true);
|
||||
const ain = centroidOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||
});
|
||||
|
||||
it('(v0.28 #46) a dist/ WITH the region-geo manifest loads it (prod layout)', () => {
|
||||
copyFileSync(
|
||||
path.join(realRoot, 'public', 'region-geo.json'),
|
||||
path.join(prodRoot, 'dist', 'region-geo.json'),
|
||||
);
|
||||
expect(loadRegions(prodRoot)).toBe(true);
|
||||
expect(hasGeo()).toBe(true);
|
||||
const ain = geoOf('ain-lqtt');
|
||||
expect(ain).toBeDefined();
|
||||
expect(ain!.area).toBeCloseTo(22557.77, 1);
|
||||
expect(ain!.population).toBe(2304126);
|
||||
});
|
||||
|
||||
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||
|
||||
+127
-6
@@ -2,7 +2,9 @@
|
||||
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||||
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||||
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
||||
// bordering region keys, built by scripts/build-region-adjacency.mjs) — all
|
||||
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
|
||||
// precomputed region-centroid manifest (region-centroids.json: region key ->
|
||||
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
|
||||
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
|
||||
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
|
||||
// joins them into the lookups the region-level ownership model needs:
|
||||
@@ -13,6 +15,10 @@
|
||||
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
||||
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||||
// field (v0.21, #16 P2)
|
||||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||||
// — the weighting source for the per-region stats model (v0.28, #46)
|
||||
//
|
||||
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||
@@ -39,12 +45,17 @@ interface RegionData {
|
||||
validKeys: Set<string>;
|
||||
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||||
adjacency: Record<string, string[]>;
|
||||
/** region key -> anchor point (from public/region-centroids.json). */
|
||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||||
geo: Record<string, { area: number; population: number }>;
|
||||
}
|
||||
|
||||
let data: RegionData | null = null;
|
||||
|
||||
/**
|
||||
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
|
||||
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
|
||||
* Idempotent.
|
||||
* The files are served to the client from `public/` in dev (Vite serves it
|
||||
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
|
||||
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
||||
@@ -52,7 +63,10 @@ let data: RegionData | null = null;
|
||||
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||||
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||||
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
||||
* off; everything else keeps working). Returns true when the core pair loaded
|
||||
* off; everything else keeps working). The centroid manifest is likewise
|
||||
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
|
||||
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
|
||||
* placed, but everything else keeps working). Returns true when the core pair loaded
|
||||
* cleanly; a missing/broken pair clears the data and returns false so the
|
||||
* server still boots and degrades gracefully (expansion becomes a no-op,
|
||||
* ownership falls back to the raw input keys).
|
||||
@@ -61,6 +75,8 @@ export function loadRegions(root: string): boolean {
|
||||
const keyFile = 'region-keys.json';
|
||||
const geoFile = 'regions.geo.json';
|
||||
const adjacencyFile = 'region-adjacency.json';
|
||||
const centroidsFile = 'region-centroids.json';
|
||||
const regionGeoFile = 'region-geo.json';
|
||||
const base = [
|
||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||
@@ -74,13 +90,13 @@ export function loadRegions(root: string): boolean {
|
||||
const geoPath = path.join(base, geoFile);
|
||||
try {
|
||||
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||
const geoJson = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||
};
|
||||
const keyToInfo: Record<string, RegionInfo> = {};
|
||||
const keysByCountry: Record<string, string[]> = {};
|
||||
const validKeys = new Set<string>();
|
||||
for (const f of geo.features ?? []) {
|
||||
for (const f of geoJson.features ?? []) {
|
||||
const id = f.id == null ? '' : String(f.id);
|
||||
const key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
@@ -105,7 +121,71 @@ export function loadRegions(root: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
|
||||
// Centroids are optional too (v0.27, #44): a missing/broken file means
|
||||
// "no centroid data" — new markers can't be placed (their adds are
|
||||
// dropped), but everything else keeps working.
|
||||
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
|
||||
const centroidsPath = path.join(base, centroidsFile);
|
||||
if (existsSync(centroidsPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ lat?: unknown; lon?: unknown; spread?: unknown }
|
||||
>;
|
||||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (
|
||||
v &&
|
||||
typeof v.lat === 'number' &&
|
||||
typeof v.lon === 'number' &&
|
||||
typeof v.spread === 'number' &&
|
||||
Number.isFinite(v.lat) &&
|
||||
Number.isFinite(v.lon) &&
|
||||
Number.isFinite(v.spread)
|
||||
) {
|
||||
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
|
||||
centroids = {};
|
||||
}
|
||||
}
|
||||
|
||||
// The region-geo manifest (v0.28, #46): area (km²) + population per region
|
||||
// key — the weighting source for the per-region stats model. OPTIONAL like
|
||||
// the others: a missing/broken file means "no geo data", and weighting
|
||||
// degrades to uniform (every region weight 1) while everything else keeps
|
||||
// working.
|
||||
let geo: Record<string, { area: number; population: number }> = {};
|
||||
const geoDataPath = path.join(base, regionGeoFile);
|
||||
if (existsSync(geoDataPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ area?: unknown; population?: unknown }
|
||||
>;
|
||||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (
|
||||
v &&
|
||||
typeof v.area === 'number' &&
|
||||
Number.isFinite(v.area) &&
|
||||
v.area >= 0 &&
|
||||
typeof v.population === 'number' &&
|
||||
Number.isFinite(v.population) &&
|
||||
v.population >= 0
|
||||
) {
|
||||
geo[k] = { area: v.area, population: v.population };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region-geo.json, continuing without it', err);
|
||||
geo = {};
|
||||
}
|
||||
}
|
||||
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
|
||||
return validKeys.size > 0;
|
||||
} catch (err) {
|
||||
console.error('[regions] failed to load region data', err);
|
||||
@@ -189,6 +269,47 @@ export function hasAdjacency(): boolean {
|
||||
return data !== null && Object.keys(data.adjacency).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-centroid manifest loaded with at least one entry
|
||||
* (v0.27, #44). The server's jump route uses this to decide whether to hand
|
||||
* the engine a centroid resolver (which lets a turn place new map markers)
|
||||
* or skip marker placement (existing markers are still maintained).
|
||||
*/
|
||||
export function hasCentroids(): boolean {
|
||||
return data !== null && Object.keys(data.centroids).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-geo manifest loaded with at least one entry (v0.28,
|
||||
* #46). The server's jump route uses this to decide whether to hand the
|
||||
* engine a geo resolver (which weights the per-region stats derivation by
|
||||
* area×population) or fall back to a uniform (unweighted) median.
|
||||
*/
|
||||
export function hasGeo(): boolean {
|
||||
return data !== null && Object.keys(data.geo).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed anchor point for a region key: the centroid of its
|
||||
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||||
* dimension, in degrees) the engine scatters markers within
|
||||
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
|
||||
* centroid data is loaded (v0.27, #44).
|
||||
*/
|
||||
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
|
||||
return data?.centroids[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
|
||||
* weighting source for the per-region stats model. `undefined` for an unknown
|
||||
* key or when no geo data is loaded (the derivation then falls back to a
|
||||
* uniform weight of 1 for that region).
|
||||
*/
|
||||
export function geoOf(key: string): { area: number; population: number } | undefined {
|
||||
return data?.geo[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* The union of the region keys that share a land border with any of the given
|
||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||
|
||||
@@ -31,6 +31,13 @@ export interface StubPayload {
|
||||
* is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
unowned?: string[];
|
||||
/**
|
||||
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
|
||||
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||
* never places or removes a marker (see deterministicTurn), so the marker
|
||||
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -61,6 +68,15 @@ function truncate(s: string, n: number): string {
|
||||
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||||
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.29, #46 PR 2) The stub likewise NEVER changes per-region stats (it
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -122,10 +138,12 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
regionChanges: [], // the stub never changes per-region stats (see doc comment)
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,37 @@ import type {
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
MarkerType,
|
||||
Nation,
|
||||
NationStats,
|
||||
RelationStatus,
|
||||
RegionStats,
|
||||
StatKey,
|
||||
} from './types';
|
||||
import { MARKER_TYPES, STAT_KEYS } from './types';
|
||||
|
||||
/** A numeric change to one of a nation's 0–100 stats. */
|
||||
export type StateChange =
|
||||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||||
| { nation: string; stat: StatKey; op: 'add'; value: number };
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) A numeric change to one of **one region's** 0–100 stats —
|
||||
* the fine-grained, per-region lever that complements the nation-wide
|
||||
* {@link StateChange}. `region` names the target by its **region key**
|
||||
* (preferred) or its human-readable **name** (the engine resolves it
|
||||
* case-insensitively, mirroring `resolveNationRef`); after validation it holds
|
||||
* the resolved **region key**. `op` is `set` (replace the value) or `add`
|
||||
* (delta, clamped to 0–100 by the engine).
|
||||
*/
|
||||
export type RegionStatChange = {
|
||||
/** The target region's key (preferred) or name (resolved by the engine). */
|
||||
region: string;
|
||||
stat: StatKey;
|
||||
op: 'set' | 'add';
|
||||
value: number;
|
||||
};
|
||||
|
||||
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||||
export interface RelationChange {
|
||||
a: string;
|
||||
@@ -114,11 +135,116 @@ export interface TurnEventSpec {
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||
//
|
||||
// The LLM places named point markers (cities, factories, armies, ports,
|
||||
// capitals, fleets, rocket launch sites, gatherings) anchored to a region
|
||||
// key. It never sees or sets coordinates: the engine derives the position
|
||||
// from the region's precomputed centroid (public/region-centroids.json) plus a
|
||||
// small deterministic offset, and it recomputes each marker's owner from the
|
||||
// region's current owner on every turn (a released region → null).
|
||||
//
|
||||
// The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit →
|
||||
// base 26, the same scheme as the region keys' 4-char suffix) derived from
|
||||
// (type, name, regionKey) — so re-adding an identical marker is idempotent
|
||||
// (same id, skipped) and removing by id is unambiguous.
|
||||
|
||||
/** The spec the LLM emits to place a marker: type, name and the region it sits in. */
|
||||
export interface MarkerSpec {
|
||||
type: MarkerType;
|
||||
name: string;
|
||||
regionKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One existing marker as the user prompt presents it to the model (v0.27,
|
||||
* #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`),
|
||||
* type, name, region key, and the id of the nation currently owning its
|
||||
* region (`null` when the region is unowned / no man's land).
|
||||
*/
|
||||
export interface MarkerInfo {
|
||||
id: string;
|
||||
type: MarkerType;
|
||||
name: string;
|
||||
regionKey: string;
|
||||
owner: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-type presentation of a marker: its display label (tooltip /
|
||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||
* a small, font-safe character in front of the name). Pure + shared so the
|
||||
* client's marker layer and any server-side consumers agree on the look.
|
||||
*/
|
||||
export const MARKER_TYPE_INFO: Record<MarkerType, { label: string; color: string; glyph: string }> = {
|
||||
city: { label: 'City', color: '#cbd5e1', glyph: '●' },
|
||||
factory: { label: 'Factory', color: '#fb923c', glyph: '■' },
|
||||
army: { label: 'Army', color: '#ef4444', glyph: '▲' },
|
||||
port: { label: 'Port', color: '#38bdf8', glyph: '◆' },
|
||||
capital: { label: 'Capital', color: '#fbbf24', glyph: '★' },
|
||||
fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' },
|
||||
rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' },
|
||||
gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' },
|
||||
};
|
||||
|
||||
/** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */
|
||||
export const MAX_MARKERS = 400;
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use
|
||||
* (see scripts/build-region-keys.mjs). Exported so the engine can derive
|
||||
* deterministic per-axis jitter fractions from it (marker placement, v0.27 #44).
|
||||
*/
|
||||
export function fnv1a32(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
|
||||
function code4(h: number): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash
|
||||
* of `(type, name, regionKey)` (NUL-separated so no field boundary is
|
||||
* ambiguous). Deterministic across runs and platforms (pure string math), so
|
||||
* the same (type, name, regionKey) always maps to the same id — re-adding is
|
||||
* idempotent, removing by id is unambiguous.
|
||||
*/
|
||||
export function markerId(type: MarkerType, name: string, regionKey: string): string {
|
||||
return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`));
|
||||
}
|
||||
|
||||
/** True when `t` is one of the eight known marker types (v0.27, #44). */
|
||||
export function isMarkerType(t: unknown): t is MarkerType {
|
||||
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||
export interface TurnResult {
|
||||
date: string;
|
||||
events: TurnEventSpec[];
|
||||
stateChanges: StateChange[];
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Fine-grained, **per-region** stat changes: each targets a
|
||||
* single region's 0–100 stats (by region key or name) and complements the
|
||||
* nation-wide `stateChanges` (which apply to *all* of a nation's regions).
|
||||
* The engine resolves each `region` to a key (key first, then a
|
||||
* case-insensitive unique name match) and applies it to
|
||||
* `GameState.regionStats` only for regions that have an entry (owned); an
|
||||
* unowned / no-man's-land region is a no-op. Empty on turns where the model
|
||||
* changes no individual region. Required (default `[]`).
|
||||
*/
|
||||
regionChanges: RegionStatChange[];
|
||||
relations: RelationChange[];
|
||||
territoryChanges: TerritoryChange[];
|
||||
/**
|
||||
@@ -141,6 +267,16 @@ export interface TurnResult {
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
* deterministic offset, sets the owner to the region's post-turn owner,
|
||||
* derives the stable id, and drops unknown types/region keys, duplicates,
|
||||
* and adds beyond MAX_MARKERS); `remove` drops markers by their stable id
|
||||
* (a no-op for an id that is not present). Both required, `[]` on turns
|
||||
* with no marker activity.
|
||||
*/
|
||||
markers: { add: MarkerSpec[]; remove: string[] };
|
||||
}
|
||||
|
||||
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||
@@ -316,6 +452,42 @@ export function nationColor(id: string): string {
|
||||
return `hsl(${hue} 58% 46%)`;
|
||||
}
|
||||
|
||||
// --- Combat: the military hard gate (v0.30, #50) ------------------------------
|
||||
//
|
||||
// The one rule the engine enforces on the player's behalf instead of trusting
|
||||
// the LLM: a contested conquest only succeeds when the attacker is not MORE
|
||||
// than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes
|
||||
// the transfer; the engine decides whether it holds (see `applyTurnResult`).
|
||||
// Re-claims of unowned / no-man's-land regions are never gated.
|
||||
|
||||
/**
|
||||
* (v0.30, #50) The margin of military superiority an attacker must NOT be more
|
||||
* than below the defender for a contested conquest to succeed. A gap of exactly
|
||||
* this many points is still enough to win; a gap one larger is repelled.
|
||||
*/
|
||||
export const CONQUEST_MILITARY_MARGIN = 15;
|
||||
|
||||
/**
|
||||
* (v0.30, #50) Hard military gate for a **contested** conquest (a region
|
||||
* currently owned by a different nation). Returns `true` — the invasion
|
||||
* proceeds — unless the attacker's military is MORE than `margin` below the
|
||||
* defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}),
|
||||
* is enough to win, while a gap larger than `margin` is repelled.
|
||||
*
|
||||
* The comparison is intentionally one-sided (it only protects the *defender*
|
||||
* from an overwhelming odds gap, not the attacker from a small disadvantage):
|
||||
* `attacker < defender - margin` → repel; everything else (equal, within the
|
||||
* margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the
|
||||
* prompt's frontier-balance hint all agree on the outcome.
|
||||
*/
|
||||
export function canConquer(
|
||||
attackerMil: number,
|
||||
defenderMil: number,
|
||||
margin: number = CONQUEST_MILITARY_MARGIN,
|
||||
): boolean {
|
||||
return !(attackerMil < defenderMil - margin);
|
||||
}
|
||||
|
||||
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
|
||||
//
|
||||
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||
@@ -409,3 +581,137 @@ export function nationForPrompt(n: Nation) {
|
||||
relations: n.relations,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Per-region stats (v0.28, #46) ------------------------------------------
|
||||
//
|
||||
// A nation's 0–100 `NationStats` are no longer stored as independent per-nation
|
||||
// values: they are DERIVED as the area×population-weighted median of the stats
|
||||
// of the regions the nation owns (`GameState.regionStats`, keyed by region key).
|
||||
// These pure helpers implement that derivation, the fresh-game / legacy seeding,
|
||||
// and the one-time migration of a pre-v0.28 row. They have no runtime deps.
|
||||
|
||||
/**
|
||||
* The weighted median of `values` with per-value `weights` (paired by index).
|
||||
* The pairs are sorted by value ascending and the result is the value at the
|
||||
* first point where the cumulative weight reaches at least half the total
|
||||
* weight. An empty input, or a total weight of zero, yields 0. Non-finite or
|
||||
* non-positive weights are treated as 0 (excluded from the median).
|
||||
* Translation-invariant: adding a constant to every value shifts the result by
|
||||
* that constant — so the median of a set of **identical** values is that value
|
||||
* (the invariant the equal-value seeding and legacy migration rely on).
|
||||
* Pure + shared.
|
||||
*/
|
||||
export function weightedMedian(values: number[], weights: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const pairs = values
|
||||
.map((v, i) => {
|
||||
const w =
|
||||
typeof weights[i] === 'number' && Number.isFinite(weights[i]) && weights[i] > 0
|
||||
? weights[i]
|
||||
: 0;
|
||||
return { v: Number.isFinite(v) ? v : 0, w };
|
||||
})
|
||||
.filter((p) => p.w > 0);
|
||||
if (pairs.length === 0) return 0;
|
||||
const total = pairs.reduce((s, p) => s + p.w, 0);
|
||||
if (total <= 0) return 0;
|
||||
pairs.sort((a, b) => a.v - b.v);
|
||||
let cum = 0;
|
||||
for (const p of pairs) {
|
||||
cum += p.w;
|
||||
if (cum >= total / 2) return p.v;
|
||||
}
|
||||
return pairs[pairs.length - 1]!.v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a nation's aggregate {@link NationStats} from the stats of the regions
|
||||
* it owns: each of the four stats is the **area×population-weighted median**
|
||||
* (see {@link weightedMedian}) over the nation's owned region keys. A region's
|
||||
* weight is `(area > 0 ? area : 1) × (population > 0 ? population : 1)` — both
|
||||
* factors fall back to 1 when the region has no geo entry (`geoOf` returns
|
||||
* `undefined` for it), so every region carries weight ≥ 1 and a missing geo
|
||||
* degrades gracefully to a uniform (unweighted) median. A region key with no
|
||||
* `regionStats` entry is skipped. Returns the zero stats when the nation owns
|
||||
* no region with a stats entry (the caller's 0-region guard keeps the nation's
|
||||
* directly-maintained `n.stats` in that case). Pure + shared.
|
||||
*/
|
||||
export function deriveNationStats(
|
||||
regionKeys: string[],
|
||||
regionStats: Record<string, RegionStats>,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): NationStats {
|
||||
const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
|
||||
const byStat: Record<StatKey, number[]> = {
|
||||
military: [],
|
||||
economy: [],
|
||||
stability: [],
|
||||
population: [],
|
||||
};
|
||||
const weights: number[] = [];
|
||||
for (const key of regionKeys) {
|
||||
const rs = regionStats[key];
|
||||
if (!rs) continue; // a key with no per-region stats is skipped
|
||||
const g = geoOf?.(key);
|
||||
const a = g && g.area > 0 ? g.area : 1;
|
||||
const p = g && g.population > 0 ? g.population : 1;
|
||||
weights.push(a * p);
|
||||
for (const k of STAT_KEYS) byStat[k].push(rs[k]);
|
||||
}
|
||||
if (weights.length === 0) return zero;
|
||||
const out: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
|
||||
for (const k of STAT_KEYS) out[k] = clampStat(weightedMedian(byStat[k], weights));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a per-region stats table from a set of nations (used both at game
|
||||
* creation and by the legacy migration): every owned region key gets a **copy**
|
||||
* of its owner's current `NationStats`, so a fresh nation's regions all start
|
||||
* identical (the weighted median of identical values is that value, so the
|
||||
* derived `n.stats` equals the seeded one). A region with two or more owners
|
||||
* (corrupt) is seeded by the first owner encountered; a key owned by nobody gets
|
||||
* no entry. Pure + shared.
|
||||
*/
|
||||
export function seedRegionStats(nations: Record<string, Nation>): Record<string, RegionStats> {
|
||||
const out: Record<string, RegionStats> = {};
|
||||
for (const n of Object.values(nations)) {
|
||||
for (const key of n.territory) {
|
||||
if (!(key in out)) out[key] = { ...n.stats }; // first owner wins (sole ownership holds normally)
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.28, #46) One-time migration of a **legacy** (pre-v0.28) `GameState` row
|
||||
* into the per-region-stats model, returning a new state (the input is never
|
||||
* mutated):
|
||||
* - a row that already has `regionStats` (a v0.28+ save) is returned
|
||||
* **UNCHANGED** — its `Nation.stats` are already the correct derived medians
|
||||
* and must not be recomputed on load (that would drift them);
|
||||
* - a legacy row (`regionStats` absent at runtime) seeds every owned region key
|
||||
* with a copy of its owner's current stats (so each nation's regions start
|
||||
* identical), then recomputes each nation's stats as the weighted median of
|
||||
* its regions (a 0-region nation keeps its stats). Without `geoOf` the
|
||||
* weighting is uniform, which is weight-invariant for the identical-value
|
||||
* case, so the recomputed stats equal the originals — the migration is a
|
||||
* no-op on the visible numbers and only materializes the (previously
|
||||
* implicit) per-region values. Never throws.
|
||||
*/
|
||||
export function migrateRegionStats(
|
||||
state: GameState,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): GameState {
|
||||
// A v0.28+ row already carries per-region stats: leave it exactly as saved.
|
||||
if (state.regionStats !== undefined) return state;
|
||||
// Legacy row: materialize per-region stats from each nation's (uniform) stats.
|
||||
const next: GameState = { ...state, regionStats: seedRegionStats(state.nations) };
|
||||
for (const n of Object.values(next.nations)) {
|
||||
if (n.territory.length === 0) continue; // 0-region guard: keep its stats
|
||||
if (n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
+99
-1
@@ -13,6 +13,19 @@ export interface NationStats {
|
||||
population: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats
|
||||
* are the unit of record: a nation's aggregate {@link NationStats} is derived as
|
||||
* the area×population-weighted median of the stats of the regions it owns (see
|
||||
* `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine.
|
||||
*/
|
||||
export interface RegionStats {
|
||||
military: number;
|
||||
economy: number;
|
||||
stability: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||
export type StatKey = (typeof STAT_KEYS)[number];
|
||||
|
||||
@@ -66,7 +79,69 @@ export const EVENT_TYPES: EventType[] = [
|
||||
'cultural',
|
||||
];
|
||||
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||
/**
|
||||
* The outcome of a narrative event. `success` / `failure` / `partial` /
|
||||
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
|
||||
* reserved for the engine: it is emitted automatically when the military hard
|
||||
* gate rejects a contested conquest (an invasion whose attacker was more than
|
||||
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
|
||||
* and the client renders it in a distinct muted-red failure tone.
|
||||
*/
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
|
||||
|
||||
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||
//
|
||||
// Named point markers the LLM can place on the map (cities, factories, armies,
|
||||
// ports, capitals, fleets, rocket launch sites, political/social gatherings).
|
||||
// A marker is anchored to a **region key** (see `public/region-keys.json`): the
|
||||
// engine positions it at that region's precomputed centroid (with a small
|
||||
// deterministic offset) and its `ownerNationId` follows the region's current
|
||||
// owner (recomputed on every turn; `null` when the region is unowned / no
|
||||
// man's land). See protocol.ts for the LLM-facing `TurnResult.markers`
|
||||
// contract and the `markerId` derivation.
|
||||
|
||||
/** The eight marker categories the model may place (v0.27, #44). */
|
||||
export type MarkerType =
|
||||
| 'city'
|
||||
| 'factory'
|
||||
| 'army'
|
||||
| 'port'
|
||||
| 'capital'
|
||||
| 'fleet'
|
||||
| 'rocket'
|
||||
| 'gathering';
|
||||
|
||||
/** All marker types, in canonical order (used by the LLM contract + map layer). */
|
||||
export const MARKER_TYPES: MarkerType[] = [
|
||||
'city',
|
||||
'factory',
|
||||
'army',
|
||||
'port',
|
||||
'capital',
|
||||
'fleet',
|
||||
'rocket',
|
||||
'gathering',
|
||||
];
|
||||
|
||||
/**
|
||||
* A named point marker on the map (v0.27, #44). `id` is a stable,
|
||||
* deterministic 4-character code derived from `type + name + regionKey`
|
||||
* (see protocol.markerId) — re-adding the same marker is idempotent and
|
||||
* removing by id is unambiguous. `lat`/`lon` are filled in by the engine from
|
||||
* the region's centroid manifest (never by the model); `ownerNationId` is the
|
||||
* marker's region's current owner (`null` when unowned) and is recomputed by
|
||||
* the engine on every committed turn, so a marker always follows its region.
|
||||
*/
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
type: MarkerType;
|
||||
name: string;
|
||||
regionKey: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
ownerNationId: string | null;
|
||||
placedTurn: number;
|
||||
}
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
@@ -153,6 +228,29 @@ export interface GameState {
|
||||
* runtime dependencies.)
|
||||
*/
|
||||
pendingNationOps: NationOp[];
|
||||
/**
|
||||
* (v0.27, #44) Named point markers on the map: the LLM places them (via a
|
||||
* turn's `markers.add`) and removes them (`markers.remove`, by id). The
|
||||
* engine positions each at its region's centroid (deterministic small
|
||||
* offset) and recomputes `ownerNationId` from the region's current owner on
|
||||
* EVERY committed turn (a released region → `null`). Always `[]` on a fresh
|
||||
* game; rows saved before v0.27 load with this defaulted to `[]` (see
|
||||
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||
*/
|
||||
markers: MapMarker[];
|
||||
/**
|
||||
* (v0.28, #46) Per-region stats, keyed by **region key** (see
|
||||
* `public/region-keys.json`): each owned region carries its own four 0–100
|
||||
* stats, and a nation's aggregate `Nation.stats` is the area×population-
|
||||
* weighted median of the regions it owns (see `protocol.deriveNationStats`).
|
||||
* A region that ends a turn **unowned** (released to no man's land) has NO
|
||||
* entry, and a preset-unassigned region (never owned) has none until first
|
||||
* claimed (re-claiming re-inits it to the gainer's stats). Always present on a
|
||||
* fresh game (every owned region seeded with its owner's initial stats); a
|
||||
* legacy SQLite row without the field is migrated at load (see server/db.ts)
|
||||
* — additive, no data wipe.
|
||||
*/
|
||||
regionStats: Record<string, RegionStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -706,6 +706,8 @@ function GameScreen({
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
unowned={game.unowned ?? []}
|
||||
markers={game.markers ?? []}
|
||||
regionStats={game.regionStats ?? {}}
|
||||
onFocusNation={focusNation}
|
||||
/>
|
||||
|
||||
|
||||
+367
-9
@@ -10,12 +10,14 @@ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||
maplibregl.setWorkerUrl(workerUrl);
|
||||
import {
|
||||
mapFeatureOwner,
|
||||
MARKER_TYPE_INFO,
|
||||
nationColor,
|
||||
regionFillStyle,
|
||||
unownedTooltipLabel,
|
||||
type RegionMap,
|
||||
type RegionOwner,
|
||||
} from '@shared/protocol';
|
||||
import type { Nation } from '@shared/types';
|
||||
import { MARKER_TYPES, type MapMarker, type Nation, type RegionStats } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { idToKey } from '@/lib/regions';
|
||||
|
||||
@@ -45,8 +47,16 @@ type GeoSource = maplibregl.GeoJSONSource;
|
||||
// looked black, and `styledata` was gated on fetching a remote document. An
|
||||
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
||||
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
||||
// raster has no vector tiles, so no glyphs endpoint is needed — we don't add
|
||||
// label layers; the choropleth provides the borders.)
|
||||
// raster has no vector tiles, so the basemap itself needs no label layers —
|
||||
// the choropleth provides the borders.)
|
||||
//
|
||||
// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is
|
||||
// raster (its labels are baked into the PNG tiles), and the LLM-placed map
|
||||
// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol
|
||||
// layer — fully self-contained, so they render offline and deterministically
|
||||
// without depending on any external font/glyph endpoint (the OpenFreeMap
|
||||
// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come
|
||||
// from the raster, not from glyphs).
|
||||
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
|
||||
|
||||
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||||
@@ -88,6 +98,8 @@ const PLAYER_LINE = 'player-line';
|
||||
const FLASH_LINE = 'flash-line';
|
||||
const HOVER_FILL = 'hover-fill';
|
||||
const HOVER_LINE = 'hover-line';
|
||||
const MARKERS_SOURCE = 'markers';
|
||||
const MARKER_SYMBOL = 'markers-symbol';
|
||||
|
||||
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
|
||||
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
||||
@@ -195,6 +207,186 @@ function featuresByRegionKeys(
|
||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ------------------------------------------------
|
||||
//
|
||||
// The LLM places named point markers (cities, factories, armies, ports,
|
||||
// capitals, fleets, rocket sites, gatherings) anchored to a region key. The
|
||||
// engine positions each at its region's centroid (deterministic small offset)
|
||||
// and recomputes its owner from the region's current owner every turn. The
|
||||
// map renders them as a single top-most symbol layer: a per-type glyph
|
||||
// character in front of the name, per-type color, dark-theme halo.
|
||||
|
||||
/**
|
||||
* The GeoJSON behind the marker symbol layer: one Point per marker at
|
||||
* `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the
|
||||
* current owner's name + stable color (empty when the region is unowned).
|
||||
* Pure; the caller passes the current marker list + nations record, so a
|
||||
* renamed/removed owner is picked up on the next paint.
|
||||
*/
|
||||
function buildMarkersFC(markers: MapMarker[], nations: Record<string, Nation>): FeatureCollection {
|
||||
const features = markers.map((m) => {
|
||||
const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] },
|
||||
properties: {
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
name: m.name,
|
||||
glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '',
|
||||
owner: owner?.name ?? '',
|
||||
ownerId: owner?.id ?? '',
|
||||
ownerColor: owner ? nationColor(owner.id) : '',
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// Small canvas-path helpers for the marker icons (self-contained shapes — no
|
||||
// unicode/font dependency, so they render identically in any environment).
|
||||
function roundRectPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function starPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
spikes: number,
|
||||
outer: number,
|
||||
inner: number,
|
||||
): void {
|
||||
let rot = (Math.PI / 2) * -1;
|
||||
const step = Math.PI / spikes;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||||
for (let i = 0; i < spikes; i++) {
|
||||
rot += step;
|
||||
ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner);
|
||||
rot += step;
|
||||
ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct
|
||||
* shape per type, filled with the type's color from the shared
|
||||
* `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any
|
||||
* choropleth fill. Added to the map as a named image and referenced by the
|
||||
* symbol layer's `icon-image` — a real symbol layer, per-type color + form,
|
||||
* with zero external font/glyph dependency.
|
||||
*/
|
||||
function drawMarkerIcon(type: MapMarker['type']): ImageData {
|
||||
const S = 44;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = S;
|
||||
c.height = S;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const cx = S / 2;
|
||||
const cy = S / 2;
|
||||
ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1';
|
||||
ctx.strokeStyle = '#0b1220';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineJoin = 'round';
|
||||
switch (type) {
|
||||
case 'city':
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2);
|
||||
break;
|
||||
case 'factory': {
|
||||
const h = S * 0.5;
|
||||
roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5);
|
||||
break;
|
||||
}
|
||||
case 'army': {
|
||||
const r = S * 0.34;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx + r, cy + r * 0.8);
|
||||
ctx.lineTo(cx - r, cy + r * 0.8);
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
case 'port': {
|
||||
const r = S * 0.36;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - r);
|
||||
ctx.lineTo(cx + r, cy);
|
||||
ctx.lineTo(cx, cy + r);
|
||||
ctx.lineTo(cx - r, cy);
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
case 'capital':
|
||||
starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15);
|
||||
break;
|
||||
case 'fleet':
|
||||
case 'gathering':
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2);
|
||||
break;
|
||||
case 'rocket': {
|
||||
const w = S * 0.14;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - S * 0.36);
|
||||
ctx.lineTo(cx + S * 0.22, cy - S * 0.04);
|
||||
ctx.lineTo(cx + w, cy - S * 0.04);
|
||||
ctx.lineTo(cx + w, cy + S * 0.34);
|
||||
ctx.lineTo(cx - w, cy + S * 0.34);
|
||||
ctx.lineTo(cx - w, cy - S * 0.04);
|
||||
ctx.lineTo(cx - S * 0.22, cy - S * 0.04);
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
// fleet and gathering share a circle base; an inner mark tells them apart
|
||||
// (fleet = a dark porthole dot, gathering = a white bullseye dot).
|
||||
if (type === 'fleet' || type === 'gathering') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2);
|
||||
ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff';
|
||||
ctx.fill();
|
||||
}
|
||||
// Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe)
|
||||
// rather than the canvas element — `addImage` accepts ImageData directly.
|
||||
return ctx.getImageData(0, 0, S, S);
|
||||
}
|
||||
|
||||
/** The hover tooltip for a marker: its type label + name, then the owner chip. */
|
||||
function markerTooltipHtml(
|
||||
type: string,
|
||||
name: string,
|
||||
owner: string,
|
||||
ownerColor: string,
|
||||
): string {
|
||||
const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker';
|
||||
const ownerLine = owner
|
||||
? `<div class="hm-popup-owner"><span class="hm-chip" style="background:${ownerColor}"></span>${esc(owner)}</div>`
|
||||
: '<div class="hm-popup-sub">No owner</div>';
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(label)}: ${esc(name)}</div>${ownerLine}</div>`;
|
||||
}
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
|
||||
@@ -218,11 +410,29 @@ function hoverHtml(
|
||||
region?: RegionOwner,
|
||||
nation?: Nation,
|
||||
noMansLand = false,
|
||||
regionKey?: string,
|
||||
regionStats?: Record<string, RegionStats>,
|
||||
): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
||||
}
|
||||
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
|
||||
// derived aggregate), looked up by region key. When the region has no stats
|
||||
// entry (defensive — an owned region should always have one) every stat
|
||||
// shows a placeholder dash. The dossier (NationPanel) still shows the
|
||||
// nation's derived stats, so this tooltip is the one place a single region's
|
||||
// numbers are visible on the map.
|
||||
const rs = regionKey ? regionStats?.[regionKey] : undefined;
|
||||
const row = (label: string, v?: number): string =>
|
||||
rs == null
|
||||
? `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">–</span></div>`
|
||||
: statRow(label, v);
|
||||
const stats = [
|
||||
row('Mil', rs?.military),
|
||||
row('Eco', rs?.economy),
|
||||
row('Stab', rs?.stability),
|
||||
row('Pop', rs?.population),
|
||||
].join('');
|
||||
return `<div class="hm-popup">
|
||||
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
|
||||
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||
@@ -254,6 +464,21 @@ interface Props {
|
||||
* component doc for the full behavior.
|
||||
*/
|
||||
onFocusNation?: (id: string, info?: { name?: string }) => void;
|
||||
/**
|
||||
* (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM
|
||||
* places/removes them over time, the engine positions each at its region's
|
||||
* centroid and keeps its owner in sync with the region's owner. Rendered as
|
||||
* a top-most symbol layer (per-type glyph + name, per-type color).
|
||||
*/
|
||||
markers?: MapMarker[];
|
||||
/**
|
||||
* (v0.29, #46 PR 2) The game's per-region stats (`GameState.regionStats`,
|
||||
* keyed by region key). Used only by the hover tooltip to show a single
|
||||
* region's own four 0–100 stats (a region with no entry shows placeholders).
|
||||
* The dossier (NationPanel) is unaffected — it keeps the nation's derived
|
||||
* stats.
|
||||
*/
|
||||
regionStats?: Record<string, RegionStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,6 +511,18 @@ interface Props {
|
||||
* a plain "No owner" sub-line. The colors/labels come from the shared
|
||||
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
|
||||
*
|
||||
* (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`)
|
||||
* render on a top-most symbol layer: each is its type's canvas-generated icon
|
||||
* (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO`
|
||||
* contract, dark-outlined) at the engine-derived position (region centroid +
|
||||
* small deterministic offset). The icons are drawn into named map images
|
||||
* (`marker-<type>`), so there is no external font/glyph dependency and markers
|
||||
* render offline and deterministically. Hovering a marker shows a tooltip
|
||||
* (type label + name + owner chip); clicking one switches the dossier to the
|
||||
* owning nation (no-op when the marker's region is unowned). The region
|
||||
* hover/click handlers detect a marker under the cursor (MapLibre layer events
|
||||
* have no propagation) and act on the marker's data.
|
||||
*
|
||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||
* — no floating control and no external API.
|
||||
*/
|
||||
@@ -297,6 +534,8 @@ export default function WorldMap({
|
||||
className,
|
||||
unowned,
|
||||
onFocusNation,
|
||||
markers,
|
||||
regionStats,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
@@ -311,12 +550,17 @@ export default function WorldMap({
|
||||
const presetRef = useRef(presetId);
|
||||
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||
const onFocusNationRef = useRef(onFocusNation);
|
||||
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||||
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
|
||||
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
|
||||
regionsRef.current = regions;
|
||||
nationsRef.current = nations;
|
||||
playerRef.current = playerNationId;
|
||||
presetRef.current = presetId;
|
||||
unownedRef.current = new Set(unowned ?? []);
|
||||
onFocusNationRef.current = onFocusNation;
|
||||
markersRef.current = markers ?? [];
|
||||
regionStatsRef.current = regionStats ?? {};
|
||||
|
||||
// Diffing state for the transient territory-change flash (owned by this component
|
||||
// so a resync with identical data never re-flashes).
|
||||
@@ -424,6 +668,39 @@ export default function WorldMap({
|
||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
// (v0.27, #44) A marker rendered on top of this region wins the hover.
|
||||
// The region polygon is always beneath a marker, so this FILL_LAYER
|
||||
// delegate fires in both cases; if a marker icon is under the cursor,
|
||||
// show the marker's tooltip (name + type + owner) instead of the
|
||||
// region's. `e.point` is the cursor's pixel position, so querying the
|
||||
// marker layer there tests the icon's (small) hit area, not the whole
|
||||
// region.
|
||||
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||||
if (markerHits.length > 0) {
|
||||
const mp = (markerHits[0].properties ?? {}) as {
|
||||
type?: string;
|
||||
name?: string;
|
||||
owner?: string;
|
||||
ownerColor?: string;
|
||||
};
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(
|
||||
markerTooltipHtml(
|
||||
typeof mp.type === 'string' ? mp.type : '',
|
||||
typeof mp.name === 'string' ? mp.name : '',
|
||||
typeof mp.owner === 'string' ? mp.owner : '',
|
||||
typeof mp.ownerColor === 'string' ? mp.ownerColor : '',
|
||||
),
|
||||
)
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
// A marker has no region outline to highlight; clear the previous one
|
||||
// (and reset lastHoverId so moving back onto a region re-triggers it).
|
||||
lastHoverId = null;
|
||||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
|
||||
return;
|
||||
}
|
||||
const p = f.properties as { name?: string; regionId?: string };
|
||||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
if (subId && subId !== lastHoverId) {
|
||||
@@ -442,11 +719,11 @@ export default function WorldMap({
|
||||
// (v0.22, #16 P3) whether this region is no man's land (released
|
||||
// mid-game), so the ownerless tooltip can say so.
|
||||
const pKey = f.properties?.regionKey;
|
||||
const noMansLand =
|
||||
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
|
||||
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
|
||||
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
|
||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
@@ -466,6 +743,19 @@ export default function WorldMap({
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
// (v0.27, #44) A marker under the cursor: focus its owning nation
|
||||
// (identical to its region's owner — the engine keeps a marker's owner in
|
||||
// sync with the region) and carry the marker's name into the dossier.
|
||||
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||||
if (markerHits.length > 0) {
|
||||
const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string };
|
||||
const owner = typeof mp.ownerId === 'string' ? mp.ownerId : '';
|
||||
if (owner) {
|
||||
const info = typeof mp.name === 'string' ? { name: mp.name } : undefined;
|
||||
onFocusNationRef.current?.(owner, info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const region = mapFeatureOwner(regionsRef.current, f);
|
||||
if (!region?.ownerId) return;
|
||||
// Carry the clicked sub-region's name so the dossier can show which region
|
||||
@@ -497,6 +787,9 @@ export default function WorldMap({
|
||||
if (!map.getSource(FLASH_SOURCE)) {
|
||||
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||
}
|
||||
if (!map.getSource(MARKERS_SOURCE)) {
|
||||
map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||
}
|
||||
};
|
||||
|
||||
// Stamp ownership + colors onto the regions source (no-op until both the
|
||||
@@ -509,6 +802,26 @@ export default function WorldMap({
|
||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
||||
};
|
||||
|
||||
// Stamp the current markers onto the marker source (v0.27, #44).
|
||||
const paintMarkers = () => {
|
||||
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||||
if (!src) return;
|
||||
src.setData(buildMarkersFC(markersRef.current, nationsRef.current));
|
||||
};
|
||||
|
||||
// Register the eight canvas-generated marker-type icon images (v0.27, #44).
|
||||
// Images are cleared on a style (re)load, so this is (re)run from
|
||||
// `ensureLayers` (which fires on every `styledata`). Self-contained — no
|
||||
// external font/glyph endpoint — so markers render offline and
|
||||
// deterministically.
|
||||
const ensureMarkerImages = async () => {
|
||||
for (const type of MARKER_TYPES) {
|
||||
const id = `marker-${type}`;
|
||||
if (map.getImage(id)) continue;
|
||||
await map.addImage(id, drawMarkerIcon(type));
|
||||
}
|
||||
};
|
||||
|
||||
const ensureLayers = async () => {
|
||||
if (cancelled) return;
|
||||
ensureSources();
|
||||
@@ -528,6 +841,7 @@ export default function WorldMap({
|
||||
}
|
||||
if (cancelled) return;
|
||||
paintRegions();
|
||||
paintMarkers();
|
||||
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.
|
||||
@@ -554,8 +868,9 @@ export default function WorldMap({
|
||||
}
|
||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||
// stack is: fill, base borders, player border, territory-change flash,
|
||||
// hover brighten, hover outline. Do not use a `beforeId` here — it throws
|
||||
// if that layer does not exist yet on a fresh style load.
|
||||
// hover brighten, hover outline, and (top-most) the LLM-placed map
|
||||
// markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it
|
||||
// throws if that layer does not exist yet on a fresh style load.
|
||||
if (!map.getLayer(PLAYER_LINE)) {
|
||||
map.addLayer({
|
||||
id: PLAYER_LINE,
|
||||
@@ -589,6 +904,36 @@ export default function WorldMap({
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
|
||||
});
|
||||
}
|
||||
// (v0.27, #44) The LLM-placed map markers: a top-most symbol layer.
|
||||
// Each marker renders its type's canvas-generated icon (a distinct shape,
|
||||
// per-type color from the shared MARKER_TYPE_INFO contract) with a dark
|
||||
// outline for legibility on any choropleth fill. The icon images are
|
||||
// self-contained (no external font/glyph endpoint), so markers render
|
||||
// offline and deterministically. `icon-image` is `marker-<type>`, the
|
||||
// named canvas images registered by `ensureMarkerImages` above.
|
||||
await ensureMarkerImages();
|
||||
if (!map.getLayer(MARKER_SYMBOL)) {
|
||||
map.addLayer({
|
||||
id: MARKER_SYMBOL,
|
||||
type: 'symbol',
|
||||
source: MARKERS_SOURCE,
|
||||
layout: {
|
||||
'icon-image': ['concat', 'marker-', ['get', 'type']],
|
||||
'icon-size': 0.8,
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Marker interactions: the markers sit above the choropleth, but they
|
||||
// deliberately share the region (FILL_LAYER) handlers registered below.
|
||||
// MapLibre layer-scoped events are independent per-layer delegates (no
|
||||
// propagation, no stopPropagation), so a marker-on-region would fire
|
||||
// both a marker and a region handler. Instead the region handlers detect
|
||||
// a marker under the cursor (see onHover/onClick) and act on the
|
||||
// marker's data; clicking a marker focuses its owning nation via the
|
||||
// same path as clicking its region (the marker's owner == its region's
|
||||
// owner).
|
||||
map.off('mousemove', FILL_LAYER, onHover);
|
||||
map.off('mouseout', FILL_LAYER, onHoverOut);
|
||||
map.off('click', FILL_LAYER, onClick);
|
||||
@@ -646,6 +991,19 @@ export default function WorldMap({
|
||||
}
|
||||
}, [regions, playerNationId, unowned]);
|
||||
|
||||
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
|
||||
// changes (a turn placed/removed markers) or the nations record changes
|
||||
// (a confirmed rename/merge changes an owner's display name). No-op until
|
||||
// the map + source exist (the mount effect's ensureLayers applies the
|
||||
// current data once the layer structure is in place).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||||
if (!src) return;
|
||||
src.setData(buildMarkersFC(markers ?? [], nations));
|
||||
}, [markers, nations]);
|
||||
|
||||
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||||
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||||
// `position: relative`, which would otherwise win over a Tailwind class.
|
||||
|
||||
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
||||
failure: '#ef4444',
|
||||
partial: '#f59e0b',
|
||||
neutral: '#94a3b8',
|
||||
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
|
||||
// reads as a defensive failure distinct from a bright outright `failure`.
|
||||
repelled: '#9f1239',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user