Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
327fffb789 | ||
|
|
a129c2ab81 | ||
|
|
aa1bd3c5aa | ||
|
|
f6ad554823 | ||
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 |
@@ -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`
|
- 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)
|
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
- 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
|
`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
|
(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
|
`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
|
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`
|
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
|
- 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
|
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
||||||
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||||||
@@ -52,19 +56,21 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── 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; 189 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
│ ├── 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)
|
│ ├── regions.geo.json # 1,800 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-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,796 entries (the getNeighbours data, #16 P2)
|
│ ├── 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)
|
||||||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
│ │ # fill-gaps → build-region-keys → build-region-adjacency
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
│ │ # → build-region-adjacency → build-region-centroids
|
||||||
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
@@ -110,11 +116,13 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `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 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 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/`; 189 tests) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `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 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) |
|
||||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
| `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.
|
**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 +196,22 @@ history/
|
|||||||
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
"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` /
|
— 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
|
`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).**
|
- **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
|
`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
|
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
||||||
@@ -227,6 +250,42 @@ history/
|
|||||||
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
|
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
|
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
|
||||||
(`server/engine.test.ts`).
|
(`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`).
|
||||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
- **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
|
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
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
@@ -347,7 +406,8 @@ history/
|
|||||||
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
|
(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
|
- 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
|
(`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
|
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
|
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
|
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
|
||||||
|
|||||||
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.24.0",
|
"version": "0.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -14,8 +14,11 @@
|
|||||||
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||||
"smooth:regions": "node scripts/smooth-regions.mjs",
|
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||||
"fill:regions": "node scripts/fill-gaps.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-keys": "node scripts/build-region-keys.mjs",
|
||||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
|
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
|
|||||||
@@ -7587,6 +7587,7 @@
|
|||||||
"nenets-qyga": [
|
"nenets-qyga": [
|
||||||
"arkhangel-sk-lhbk",
|
"arkhangel-sk-lhbk",
|
||||||
"komi-eoeh",
|
"komi-eoeh",
|
||||||
|
"pechora-qwhs",
|
||||||
"yamal-nenets-aqsx"
|
"yamal-nenets-aqsx"
|
||||||
],
|
],
|
||||||
"neuquen-yvne": [
|
"neuquen-yvne": [
|
||||||
@@ -7614,6 +7615,7 @@
|
|||||||
"ontario-bzqu",
|
"ontario-bzqu",
|
||||||
"quebec-yegk"
|
"quebec-yegk"
|
||||||
],
|
],
|
||||||
|
"new-earth-eikt": [],
|
||||||
"new-hampshire-koki": [
|
"new-hampshire-koki": [
|
||||||
"maine-maiz",
|
"maine-maiz",
|
||||||
"massachusetts-ggid",
|
"massachusetts-ggid",
|
||||||
@@ -8748,6 +8750,10 @@
|
|||||||
"soriano-ixbv",
|
"soriano-ixbv",
|
||||||
"tacuarembo-yekg"
|
"tacuarembo-yekg"
|
||||||
],
|
],
|
||||||
|
"pechora-qwhs": [
|
||||||
|
"nenets-qyga",
|
||||||
|
"yamal-gosw"
|
||||||
|
],
|
||||||
"peloponnisos-wqyw": [
|
"peloponnisos-wqyw": [
|
||||||
"dytiki-ellada-mlyj",
|
"dytiki-ellada-mlyj",
|
||||||
"sterea-ellada-inyu"
|
"sterea-ellada-inyu"
|
||||||
@@ -10010,6 +10016,9 @@
|
|||||||
"medea-elbf",
|
"medea-elbf",
|
||||||
"skikda-odba"
|
"skikda-odba"
|
||||||
],
|
],
|
||||||
|
"severnaya-zemlya-awqp": [
|
||||||
|
"yamal-gosw"
|
||||||
|
],
|
||||||
"sevilla-oxma": [
|
"sevilla-oxma": [
|
||||||
"badajoz-ciwt",
|
"badajoz-ciwt",
|
||||||
"cadiz-aazc",
|
"cadiz-aazc",
|
||||||
@@ -12160,6 +12169,10 @@
|
|||||||
"iwate-uvfz",
|
"iwate-uvfz",
|
||||||
"niigata-idhx"
|
"niigata-idhx"
|
||||||
],
|
],
|
||||||
|
"yamal-gosw": [
|
||||||
|
"pechora-qwhs",
|
||||||
|
"severnaya-zemlya-awqp"
|
||||||
|
],
|
||||||
"yamal-nenets-aqsx": [
|
"yamal-nenets-aqsx": [
|
||||||
"khanty-mansiy-tkto",
|
"khanty-mansiy-tkto",
|
||||||
"komi-eoeh",
|
"komi-eoeh",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1305,6 +1305,10 @@
|
|||||||
"RU-ALT": "altay-mfuq",
|
"RU-ALT": "altay-mfuq",
|
||||||
"RU-AMU": "amur-clbp",
|
"RU-AMU": "amur-clbp",
|
||||||
"RU-ARK": "arkhangel-sk-lhbk",
|
"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-AST": "astrakhan-anec",
|
||||||
"RU-BA": "bashkortostan-uovk",
|
"RU-BA": "bashkortostan-uovk",
|
||||||
"RU-BEL": "belgorod-wxjk",
|
"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`,
|
||||||
|
);
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
* 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
|
* regions all named "Western") the 4-char codes are made distinct by
|
||||||
* re-deriving with a deterministic salt on collision, which guarantees the
|
* 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 }`
|
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||||
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
* 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 geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||||
const features = Array.isArray(geo?.features) ? geo.features : [];
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||||
|
|
||||||
if (features.length !== 1796) {
|
if (features.length !== 1800) {
|
||||||
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
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
|
// 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)
|
const western = Object.entries(manifest)
|
||||||
.filter(([, k]) => k.startsWith('western-'))
|
.filter(([, k]) => k.startsWith('western-'))
|
||||||
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
.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(', ')}`);
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('region-adjacency.json', () => {
|
|||||||
const adjacencyKeys = new Set(Object.keys(adjacency));
|
const adjacencyKeys = new Set(Object.keys(adjacency));
|
||||||
expect(adjacencyKeys).toEqual(manifestKeys);
|
expect(adjacencyKeys).toEqual(manifestKeys);
|
||||||
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
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)', () => {
|
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['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
|
||||||
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,13 +33,13 @@ describe('region-keys.json', () => {
|
|||||||
const keys = Object.values(manifest);
|
const keys = Object.values(manifest);
|
||||||
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||||
|
|
||||||
it('has exactly 1,796 entries (one per region)', () => {
|
it('has exactly 1,800 entries (one per region)', () => {
|
||||||
expect(Object.keys(manifest)).toHaveLength(1796);
|
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(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', () => {
|
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', () => {
|
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
|
// 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
|
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||||
// rebuilding the keys).
|
// rebuilding the keys).
|
||||||
expect(featureIds).toHaveLength(1796);
|
expect(featureIds).toHaveLength(1800);
|
||||||
const manifestIds = Object.keys(manifest);
|
const manifestIds = Object.keys(manifest);
|
||||||
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
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
|
// 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
|
// 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
|
// 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
|
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
|
||||||
// per-feature ids/names/parents and their order are unchanged; only the
|
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
|
||||||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
|
||||||
// the signature guards against a non-idempotent or destructive re-run.
|
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
|
||||||
// The fill is byte-deterministic per input and idempotent (a second run
|
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
|
||||||
// on the filled file changes nothing).
|
// 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);
|
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||||
let verts = 0,
|
let verts = 0,
|
||||||
rings = 0,
|
rings = 0,
|
||||||
@@ -556,15 +562,67 @@ describe('regions.geo.json', () => {
|
|||||||
for (const ring of poly) verts += ring.length;
|
for (const ring of poly) verts += ring.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
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
|
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(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(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
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(
|
console.log(
|
||||||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
|
||||||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
`${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,7 @@ function makeState(id: string): GameState {
|
|||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,17 @@ describe('openDb', () => {
|
|||||||
db.close();
|
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('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export function openDb(filePath: string): Db {
|
|||||||
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||||
// has none awaiting confirm/reject.
|
// has none awaiting confirm/reject.
|
||||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
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 = [];
|
||||||
return state;
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+276
-17
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
import type { GameState, MarkerType, Preset, RoundItem } from '../shared/types';
|
||||||
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
import { makeEvent, markerId, MAX_MARKERS, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
|
||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
removeRoundItem,
|
removeRoundItem,
|
||||||
resolveNationRef,
|
resolveNationRef,
|
||||||
runTurn,
|
runTurn,
|
||||||
|
validateMarkerOps,
|
||||||
validateNationOps,
|
validateNationOps,
|
||||||
validateTurnResult,
|
validateTurnResult,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
@@ -87,6 +88,7 @@ function baseState(): GameState {
|
|||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +526,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }],
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withOps).not.toBeNull();
|
expect(withOps).not.toBeNull();
|
||||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
@@ -545,7 +547,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
@@ -565,7 +567,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn
|
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([
|
expect(next.pendingNationOps).toEqual([
|
||||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
@@ -583,7 +585,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1039,7 +1041,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1055,7 +1057,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1077,7 +1079,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -1102,7 +1104,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -1119,7 +1121,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
stateChanges: [],
|
stateChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -1208,7 +1210,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [], markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -1323,7 +1325,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
|
|
||||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1630,9 +1632,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
it('is true with an event, false when everything is empty', () => {
|
||||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
@@ -1644,8 +1646,265 @@ describe('isMeaningful', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }],
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) a turn that only adds a marker is meaningful (it survives the retry/nudge path)', () => {
|
||||||
|
expect(
|
||||||
|
isMeaningful({
|
||||||
|
date: 'x',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('markers (point placement, v0.27 #44)', () => {
|
||||||
|
// a deterministic centroid for one region key, undefined for everything else
|
||||||
|
const centroidOf = (key: string) =>
|
||||||
|
key === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
|
||||||
|
|
||||||
|
// build a MarkerSpec from a (possibly invalid, for the guard tests) type string
|
||||||
|
const mk = (type: string, name: string, regionKey: string): MarkerSpec =>
|
||||||
|
({ type: type as MarkerType, name, regionKey });
|
||||||
|
|
||||||
|
it('adds a valid marker with a stable id, a jittered position within the spread, the region owner, and the placed turn', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.turn = 4; // placedTurn should equal the resolved turn
|
||||||
|
state.nations.uk.territory = ['GBR', 'moselle-rygy']; // the region is owned by uk
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||||
|
expect(next.markers).toHaveLength(1);
|
||||||
|
const m = next.markers[0];
|
||||||
|
expect(m.id).toBe(markerId('city', 'Strasbourg', 'moselle-rygy'));
|
||||||
|
expect(m.type).toBe('city');
|
||||||
|
expect(m.name).toBe('Strasbourg');
|
||||||
|
expect(m.regionKey).toBe('moselle-rygy');
|
||||||
|
expect(m.ownerNationId).toBe('uk'); // the region's sole owner
|
||||||
|
expect(m.placedTurn).toBe(4);
|
||||||
|
// the position is the centroid jittered within ±spread/2
|
||||||
|
expect(m.lat).toBeGreaterThanOrEqual(47.5);
|
||||||
|
expect(m.lat).toBeLessThanOrEqual(48.5);
|
||||||
|
expect(m.lon).toBeGreaterThanOrEqual(5.5);
|
||||||
|
expect(m.lon).toBeLessThanOrEqual(6.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: re-adding the same (type, name, regionKey) does not duplicate the marker', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.uk.territory = ['moselle-rygy'];
|
||||||
|
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||||
|
const first = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
|
||||||
|
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||||
|
}, undefined, centroidOf);
|
||||||
|
expect(first.markers).toHaveLength(1);
|
||||||
|
// a second turn proposes the exact same marker again
|
||||||
|
const second = applyTurnResult(first, {
|
||||||
|
date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [],
|
||||||
|
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||||
|
}, undefined, centroidOf);
|
||||||
|
expect(second.markers).toHaveLength(1);
|
||||||
|
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTurnResult drops a new add whose region is unknown or has no centroid, but keeps a valid one', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.uk.territory = ['moselle-rygy'];
|
||||||
|
// validKeys knows two keys; the centroid resolves only the first.
|
||||||
|
const validKeys = new Set(['moselle-rygy', 'other-key']);
|
||||||
|
const centroidOf2 = (k: string) =>
|
||||||
|
k === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined;
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
markers: {
|
||||||
|
add: [
|
||||||
|
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||||
|
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
|
||||||
|
{ type: 'city', name: 'Unknown', regionKey: 'zzz-not-a-key' }, // not in validKeys → dropped
|
||||||
|
],
|
||||||
|
remove: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result, validKeys, centroidOf2);
|
||||||
|
expect(next.markers).toHaveLength(1);
|
||||||
|
expect(next.markers[0].name).toBe('Good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the total marker count at MAX_MARKERS, dropping the newest add that exceeds the cap', () => {
|
||||||
|
const state = baseState();
|
||||||
|
// pre-seed the state with MAX_MARKERS - 1 distinct markers
|
||||||
|
const seeded: typeof state.markers = [];
|
||||||
|
for (let i = 0; i < MAX_MARKERS - 1; i++) {
|
||||||
|
const k = `seed-${i}`;
|
||||||
|
seeded.push({
|
||||||
|
id: markerId('city', `S${i}`, k),
|
||||||
|
type: 'city',
|
||||||
|
name: `S${i}`,
|
||||||
|
regionKey: k,
|
||||||
|
lat: 0, lon: 0,
|
||||||
|
ownerNationId: null,
|
||||||
|
placedTurn: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.markers = seeded;
|
||||||
|
// a permissive centroid: every region key resolves
|
||||||
|
const anyCentroid = () => ({ lat: 0, lon: 0, spread: 0 });
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
markers: {
|
||||||
|
add: [
|
||||||
|
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||||
|
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
|
||||||
|
],
|
||||||
|
remove: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result, undefined, anyCentroid);
|
||||||
|
expect(next.markers).toHaveLength(MAX_MARKERS); // capped, not MAX_MARKERS + 1
|
||||||
|
expect(next.markers.some((m) => m.name === 'NewA')).toBe(true); // the 1st new one kept
|
||||||
|
expect(next.markers.some((m) => m.name === 'NewB')).toBe(false); // the 2nd (over cap) dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a marker by id; removing an absent id is a no-op', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.uk.territory = ['moselle-rygy'];
|
||||||
|
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
|
||||||
|
const idB = markerId('port', 'Dunkirk', 'moselle-rygy');
|
||||||
|
state.markers = [
|
||||||
|
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||||||
|
{ id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||||||
|
];
|
||||||
|
const next = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
|
||||||
|
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||||
|
}, undefined, centroidOf);
|
||||||
|
expect(next.markers).toHaveLength(1);
|
||||||
|
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a marker owner follows its region: released to no man\'s land → null; re-claimed → the new owner', () => {
|
||||||
|
const state = baseState();
|
||||||
|
state.nations.uk.territory = ['moselle-rygy'];
|
||||||
|
const idA = markerId('city', 'Strasbourg', 'moselle-rygy');
|
||||||
|
state.markers = [
|
||||||
|
{ id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
|
||||||
|
];
|
||||||
|
// 1) uk releases the region → the marker becomes ownerless
|
||||||
|
const released = applyTurnResult(state, {
|
||||||
|
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
|
||||||
|
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
|
||||||
|
}, undefined, centroidOf);
|
||||||
|
expect(released.markers[0].ownerNationId).toBeNull();
|
||||||
|
// 2) de re-claims the region → the marker's owner follows to de
|
||||||
|
const reclaimed = applyTurnResult(released, {
|
||||||
|
date: '1939-09-05', events: [], stateChanges: [], relations: [],
|
||||||
|
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||||
|
unowned: [], nationOps: [], markers: { add: [], remove: [] },
|
||||||
|
}, undefined, centroidOf);
|
||||||
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the user prompt carries the existing markers when provided (and omits the field otherwise)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const info = {
|
||||||
|
id: markerId('city', 'Strasbourg', 'moselle-rygy'),
|
||||||
|
type: 'city' as MarkerType,
|
||||||
|
name: 'Strasbourg',
|
||||||
|
regionKey: 'moselle-rygy',
|
||||||
|
owner: 'uk',
|
||||||
|
};
|
||||||
|
const payload = JSON.parse(
|
||||||
|
buildUserPrompt(state, [], undefined, undefined, undefined, [info]),
|
||||||
|
) as { markers?: Array<Record<string, unknown>> };
|
||||||
|
expect(payload.markers).toHaveLength(1);
|
||||||
|
expect(payload.markers![0].name).toBe('Strasbourg');
|
||||||
|
expect(payload.markers![0].owner).toBe('uk');
|
||||||
|
// a bare call (no markers arg) omits the field entirely
|
||||||
|
const bare = JSON.parse(buildUserPrompt(state, [])) as { markers?: unknown };
|
||||||
|
expect(bare.markers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the system prompt documents the markers contract (field + placement rule + a type)', () => {
|
||||||
|
const prompt = buildSystemPrompt(preset);
|
||||||
|
expect(prompt).toMatch(/named points on the map/i);
|
||||||
|
expect(prompt).toMatch(/"markers"/);
|
||||||
|
expect(prompt).toMatch(/"add"/);
|
||||||
|
expect(prompt).toMatch(/"remove"/);
|
||||||
|
expect(prompt).toMatch(/gathering/);
|
||||||
|
expect(prompt).toMatch(/no man's land/); // owner recompute on release
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateMarkerOps cleans a raw markers field: drops bad types/empty names, dedupes, trims removes', () => {
|
||||||
|
const raw = {
|
||||||
|
add: [
|
||||||
|
{ type: 'city', name: 'X', regionKey: 'a' },
|
||||||
|
{ type: 'not-a-type', name: 'Y', regionKey: 'a' }, // invalid type -> dropped
|
||||||
|
{ type: 'city', name: ' ', regionKey: 'a' }, // empty name -> dropped
|
||||||
|
{ type: 'city', name: 'X', regionKey: 'a' }, // duplicate (same id) -> deduped
|
||||||
|
{ type: 'port', name: 'Z', regionKey: 'b' }, // valid
|
||||||
|
],
|
||||||
|
remove: ['id-1', 42, '', ' id-2 ', 'id-1'], // number/empty dropped, trimmed, deduped
|
||||||
|
};
|
||||||
|
const out = validateMarkerOps(raw);
|
||||||
|
expect(out.add.map((m) => m.name).sort()).toEqual(['X', 'Z']);
|
||||||
|
expect(out.remove).toEqual(['id-1', 'id-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateMarkerOps returns empty ops for a non-object raw value (a string, an array, null)', () => {
|
||||||
|
for (const bad of ['oops', [{ type: 'city', name: 'X', regionKey: 'a' }], null, undefined]) {
|
||||||
|
expect(validateMarkerOps(bad)).toEqual({ add: [], remove: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runTurn wires the centroid through: a marker added over the LLM path is placed (owner = region owner)', async () => {
|
||||||
|
const result = JSON.stringify({
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
|
});
|
||||||
|
const { state } = await runTurn({
|
||||||
|
llm: fakeLlm([result]),
|
||||||
|
preset,
|
||||||
|
state: baseState(),
|
||||||
|
items: [],
|
||||||
|
centroidOf,
|
||||||
|
});
|
||||||
|
expect(state.markers).toHaveLength(1);
|
||||||
|
expect(state.markers[0].name).toBe('Strasbourg');
|
||||||
|
expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+170
-10
@@ -7,6 +7,7 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
MapMarker,
|
||||||
Nation,
|
Nation,
|
||||||
NationStats,
|
NationStats,
|
||||||
Preset,
|
Preset,
|
||||||
@@ -18,9 +19,15 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
|||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
clampStat,
|
clampStat,
|
||||||
|
fnv1a32,
|
||||||
isDateLike,
|
isDateLike,
|
||||||
|
isMarkerType,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
|
markerId,
|
||||||
|
MAX_MARKERS,
|
||||||
nationForPrompt,
|
nationForPrompt,
|
||||||
|
type MarkerInfo,
|
||||||
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
@@ -187,12 +194,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
' {"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>"}',
|
' {"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>"]}',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and region keys that exist in the provided state.',
|
'- 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.',
|
'- 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".',
|
'- 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".',
|
'- 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.',
|
'- 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.',
|
'- 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.',
|
||||||
@@ -232,6 +241,14 @@ export function buildUserPrompt(
|
|||||||
* Omitted entirely when undefined.
|
* Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
noMansLand?: string[],
|
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 {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -270,6 +287,7 @@ export function buildUserPrompt(
|
|||||||
if (regionReference) payload.regions = regionReference;
|
if (regionReference) payload.regions = regionReference;
|
||||||
if (frontier !== undefined) payload.frontier = frontier;
|
if (frontier !== undefined) payload.frontier = frontier;
|
||||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||||
|
if (markers !== undefined) payload.markers = markers;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +411,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(o.events)) {
|
if (Array.isArray(o.events)) {
|
||||||
@@ -482,9 +501,51 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
|||||||
// list for the player to confirm/reject.
|
// list for the player to confirm/reject.
|
||||||
result.nationOps = validateNationOps(o.nationOps);
|
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);
|
||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
* 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
|
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||||
@@ -498,7 +559,10 @@ export function isMeaningful(result: TurnResult): boolean {
|
|||||||
result.events.length > 0 ||
|
result.events.length > 0 ||
|
||||||
result.stateChanges.length > 0 ||
|
result.stateChanges.length > 0 ||
|
||||||
result.unowned.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,13 +580,25 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||||
* events, apply stat changes (clamped), symmetric relations, and territory
|
* events, apply stat changes (clamped), symmetric relations, territory moves
|
||||||
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
|
* (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27
|
||||||
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
|
* #44). It also appends any proposed nation-ops to the pending list (v0.23
|
||||||
* them — see the doc comment on `pendingNationOps` for why that list
|
* #16 P4a) without applying them — see the doc comment on `pendingNationOps`
|
||||||
* deliberately survives across turns.
|
* 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.
|
||||||
*/
|
*/
|
||||||
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,
|
||||||
|
): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
@@ -636,6 +712,73 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
|||||||
}
|
}
|
||||||
|
|
||||||
next.unowned = [...unowned];
|
next.unowned = [...unowned];
|
||||||
|
|
||||||
|
// --- 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
|
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
|
||||||
// are appended to the pending list, which (unlike `pending`) SURVIVES
|
// are appended to the pending list, which (unlike `pending`) SURVIVES
|
||||||
// across turns until the player confirms or rejects each one. `next` is a
|
// across turns until the player confirms or rejects each one. `next` is a
|
||||||
@@ -965,15 +1108,31 @@ export async function runTurn(opts: {
|
|||||||
* switches the turn onto the getNeighbours tool path.
|
* switches the turn onto the getNeighbours tool path.
|
||||||
*/
|
*/
|
||||||
getNeighbours?: (keys: string[]) => string[];
|
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;
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
|
||||||
const chatWithTools = llm.chatWithTools;
|
const chatWithTools = llm.chatWithTools;
|
||||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||||
const system = buildSystemPrompt(preset, useTools);
|
const system = buildSystemPrompt(preset, useTools);
|
||||||
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
||||||
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
|
// (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.
|
// 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 =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|
||||||
@@ -1018,10 +1177,11 @@ export async function runTurn(opts: {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: applyTurnResult(state, result, validRegionKeys), result };
|
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
+8
-1
@@ -22,7 +22,7 @@ import { createLlmClient } from './llm';
|
|||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, hasAdjacency, hasCentroids, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
@@ -134,6 +134,8 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||||
// shrinks as the player confirms or rejects them.
|
// shrinks as the player confirms or rejects them.
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
|
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
// 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
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
@@ -288,6 +290,11 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||||
// tool and the player-frontier prompt field (both switch off without it).
|
// tool and the player-frontier prompt field (both switch off without it).
|
||||||
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
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,
|
||||||
});
|
});
|
||||||
db.save(state);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const state: GameState = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
+53
-4
@@ -5,8 +5,10 @@ import { tmpdir } from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
allTerritoryKeys,
|
allTerritoryKeys,
|
||||||
|
centroidOf,
|
||||||
expandCountriesToKeys,
|
expandCountriesToKeys,
|
||||||
hasAdjacency,
|
hasAdjacency,
|
||||||
|
hasCentroids,
|
||||||
hasRegionData,
|
hasRegionData,
|
||||||
loadRegions,
|
loadRegions,
|
||||||
neighboursOf,
|
neighboursOf,
|
||||||
@@ -27,9 +29,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(loadRegions(root)).toBe(true);
|
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(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', () => {
|
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
||||||
@@ -51,6 +53,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
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', () => {
|
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
||||||
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||||
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
@@ -88,6 +105,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
@@ -118,6 +136,19 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(neighboursOf([])).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
describe('graceful degradation with no region data', () => {
|
describe('graceful degradation with no region data', () => {
|
||||||
beforeEach(() => resetRegions());
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
@@ -131,6 +162,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
// and no adjacency: the LLM tool / frontier field switch off
|
// and no adjacency: the LLM tool / frontier field switch off
|
||||||
expect(hasAdjacency()).toBe(false);
|
expect(hasAdjacency()).toBe(false);
|
||||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,13 +200,13 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
expect(loadRegions(prodRoot)).toBe(true);
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
expect(hasRegionData()).toBe(true);
|
expect(hasRegionData()).toBe(true);
|
||||||
expect(validKeys().size).toBe(1796);
|
expect(validKeys().size).toBe(1800);
|
||||||
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
expect(loadRegions(realRoot)).toBe(true);
|
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 []', () => {
|
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||||
@@ -182,6 +216,21 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
expect(loadRegions(prodRoot)).toBe(true);
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
expect(hasAdjacency()).toBe(false);
|
expect(hasAdjacency()).toBe(false);
|
||||||
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
|
|||||||
+67
-4
@@ -2,7 +2,9 @@
|
|||||||
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||||||
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||||||
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
// 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
|
// 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
|
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
|
||||||
// joins them into the lookups the region-level ownership model needs:
|
// joins them into the lookups the region-level ownership model needs:
|
||||||
@@ -13,6 +15,8 @@
|
|||||||
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
// - 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
|
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||||||
// field (v0.21, #16 P2)
|
// 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)
|
||||||
//
|
//
|
||||||
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
@@ -39,12 +43,15 @@ interface RegionData {
|
|||||||
validKeys: Set<string>;
|
validKeys: Set<string>;
|
||||||
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||||||
adjacency: Record<string, string[]>;
|
adjacency: Record<string, string[]>;
|
||||||
|
/** region key -> anchor point (from public/region-centroids.json). */
|
||||||
|
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: RegionData | null = null;
|
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
|
* 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
|
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
|
||||||
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
||||||
@@ -52,7 +59,10 @@ let data: RegionData | null = null;
|
|||||||
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||||||
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||||||
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
* 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
|
* cleanly; a missing/broken pair clears the data and returns false so the
|
||||||
* server still boots and degrades gracefully (expansion becomes a no-op,
|
* server still boots and degrades gracefully (expansion becomes a no-op,
|
||||||
* ownership falls back to the raw input keys).
|
* ownership falls back to the raw input keys).
|
||||||
@@ -61,6 +71,7 @@ export function loadRegions(root: string): boolean {
|
|||||||
const keyFile = 'region-keys.json';
|
const keyFile = 'region-keys.json';
|
||||||
const geoFile = 'regions.geo.json';
|
const geoFile = 'regions.geo.json';
|
||||||
const adjacencyFile = 'region-adjacency.json';
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
|
const centroidsFile = 'region-centroids.json';
|
||||||
const base = [
|
const base = [
|
||||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
@@ -105,7 +116,38 @@ 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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids };
|
||||||
return validKeys.size > 0;
|
return validKeys.size > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[regions] failed to load region data', err);
|
console.error('[regions] failed to load region data', err);
|
||||||
@@ -189,6 +231,27 @@ export function hasAdjacency(): boolean {
|
|||||||
return data !== null && Object.keys(data.adjacency).length > 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 union of the region keys that share a land border with any of the given
|
* 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
|
* 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.
|
* is covered by unit tests against applyTurnResult, not by the stub.
|
||||||
*/
|
*/
|
||||||
unowned?: string[];
|
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 {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -61,6 +68,10 @@ function truncate(s: string, n: number): string {
|
|||||||
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||||||
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -126,6 +137,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
territoryChanges,
|
territoryChanges,
|
||||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
nationOps: [], // the stub never proposes nation ops (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,10 +6,12 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { MARKER_TYPES } from './types';
|
||||||
|
|
||||||
/** A numeric change to one of a nation's 0–100 stats. */
|
/** A numeric change to one of a nation's 0–100 stats. */
|
||||||
export type StateChange =
|
export type StateChange =
|
||||||
@@ -114,6 +116,100 @@ export interface TurnEventSpec {
|
|||||||
outcome: EventOutcome;
|
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. */
|
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -141,6 +237,16 @@ export interface TurnResult {
|
|||||||
* survivor. Empty on turns where the model proposes none.
|
* survivor. Empty on turns where the model proposes none.
|
||||||
*/
|
*/
|
||||||
nationOps: NationOp[];
|
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. */
|
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||||
|
|||||||
@@ -68,6 +68,60 @@ export const EVENT_TYPES: EventType[] = [
|
|||||||
|
|
||||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||||
|
|
||||||
|
// --- 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. */
|
/** A narrative event the simulation emitted on some turn. */
|
||||||
export interface EventEntry {
|
export interface EventEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -153,6 +207,16 @@ export interface GameState {
|
|||||||
* runtime dependencies.)
|
* runtime dependencies.)
|
||||||
*/
|
*/
|
||||||
pendingNationOps: NationOp[];
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ function GameScreen({
|
|||||||
nations={game.nations}
|
nations={game.nations}
|
||||||
presetId={game.presetId}
|
presetId={game.presetId}
|
||||||
unowned={game.unowned ?? []}
|
unowned={game.unowned ?? []}
|
||||||
|
markers={game.markers ?? []}
|
||||||
onFocusNation={focusNation}
|
onFocusNation={focusNation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+333
-5
@@ -10,12 +10,14 @@ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
|||||||
maplibregl.setWorkerUrl(workerUrl);
|
maplibregl.setWorkerUrl(workerUrl);
|
||||||
import {
|
import {
|
||||||
mapFeatureOwner,
|
mapFeatureOwner,
|
||||||
|
MARKER_TYPE_INFO,
|
||||||
|
nationColor,
|
||||||
regionFillStyle,
|
regionFillStyle,
|
||||||
unownedTooltipLabel,
|
unownedTooltipLabel,
|
||||||
type RegionMap,
|
type RegionMap,
|
||||||
type RegionOwner,
|
type RegionOwner,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
import type { Nation } from '@shared/types';
|
import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { idToKey } from '@/lib/regions';
|
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
|
// looked black, and `styledata` was gated on fetching a remote document. An
|
||||||
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
||||||
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
// 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
|
// raster has no vector tiles, so the basemap itself needs no label layers —
|
||||||
// label layers; the choropleth provides the borders.)
|
// 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 NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
|
||||||
|
|
||||||
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||||||
@@ -88,6 +98,8 @@ const PLAYER_LINE = 'player-line';
|
|||||||
const FLASH_LINE = 'flash-line';
|
const FLASH_LINE = 'flash-line';
|
||||||
const HOVER_FILL = 'hover-fill';
|
const HOVER_FILL = 'hover-fill';
|
||||||
const HOVER_LINE = 'hover-line';
|
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. */
|
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
|
||||||
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
||||||
@@ -195,6 +207,186 @@ function featuresByRegionKeys(
|
|||||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
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) =>
|
const esc = (s: string) =>
|
||||||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||||
|
|
||||||
@@ -254,6 +446,13 @@ interface Props {
|
|||||||
* component doc for the full behavior.
|
* component doc for the full behavior.
|
||||||
*/
|
*/
|
||||||
onFocusNation?: (id: string, info?: { name?: string }) => void;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,6 +485,18 @@ interface Props {
|
|||||||
* a plain "No owner" sub-line. The colors/labels come from the shared
|
* a plain "No owner" sub-line. The colors/labels come from the shared
|
||||||
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
|
* `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)
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
* — no floating control and no external API.
|
* — no floating control and no external API.
|
||||||
*/
|
*/
|
||||||
@@ -297,6 +508,7 @@ export default function WorldMap({
|
|||||||
className,
|
className,
|
||||||
unowned,
|
unowned,
|
||||||
onFocusNation,
|
onFocusNation,
|
||||||
|
markers,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
@@ -311,12 +523,14 @@ export default function WorldMap({
|
|||||||
const presetRef = useRef(presetId);
|
const presetRef = useRef(presetId);
|
||||||
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||||
const onFocusNationRef = useRef(onFocusNation);
|
const onFocusNationRef = useRef(onFocusNation);
|
||||||
|
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||||||
regionsRef.current = regions;
|
regionsRef.current = regions;
|
||||||
nationsRef.current = nations;
|
nationsRef.current = nations;
|
||||||
playerRef.current = playerNationId;
|
playerRef.current = playerNationId;
|
||||||
presetRef.current = presetId;
|
presetRef.current = presetId;
|
||||||
unownedRef.current = new Set(unowned ?? []);
|
unownedRef.current = new Set(unowned ?? []);
|
||||||
onFocusNationRef.current = onFocusNation;
|
onFocusNationRef.current = onFocusNation;
|
||||||
|
markersRef.current = markers ?? [];
|
||||||
|
|
||||||
// Diffing state for the transient territory-change flash (owned by this component
|
// Diffing state for the transient territory-change flash (owned by this component
|
||||||
// so a resync with identical data never re-flashes).
|
// so a resync with identical data never re-flashes).
|
||||||
@@ -424,6 +638,39 @@ export default function WorldMap({
|
|||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
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 p = f.properties as { name?: string; regionId?: string };
|
||||||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
if (subId && subId !== lastHoverId) {
|
if (subId && subId !== lastHoverId) {
|
||||||
@@ -466,6 +713,19 @@ export default function WorldMap({
|
|||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
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);
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
if (!region?.ownerId) return;
|
if (!region?.ownerId) return;
|
||||||
// Carry the clicked sub-region's name so the dossier can show which region
|
// Carry the clicked sub-region's name so the dossier can show which region
|
||||||
@@ -497,6 +757,9 @@ export default function WorldMap({
|
|||||||
if (!map.getSource(FLASH_SOURCE)) {
|
if (!map.getSource(FLASH_SOURCE)) {
|
||||||
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
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
|
// Stamp ownership + colors onto the regions source (no-op until both the
|
||||||
@@ -509,6 +772,26 @@ export default function WorldMap({
|
|||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
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 () => {
|
const ensureLayers = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
ensureSources();
|
ensureSources();
|
||||||
@@ -528,6 +811,7 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
paintRegions();
|
paintRegions();
|
||||||
|
paintMarkers();
|
||||||
if (!map.getLayer(FILL_LAYER)) {
|
if (!map.getLayer(FILL_LAYER)) {
|
||||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||||
// colors read clearly even on the dark background / over the terrain.
|
// colors read clearly even on the dark background / over the terrain.
|
||||||
@@ -554,8 +838,9 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||||
// stack is: fill, base borders, player border, territory-change flash,
|
// stack is: fill, base borders, player border, territory-change flash,
|
||||||
// hover brighten, hover outline. Do not use a `beforeId` here — it throws
|
// hover brighten, hover outline, and (top-most) the LLM-placed map
|
||||||
// if that layer does not exist yet on a fresh style load.
|
// 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)) {
|
if (!map.getLayer(PLAYER_LINE)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: PLAYER_LINE,
|
id: PLAYER_LINE,
|
||||||
@@ -589,6 +874,36 @@ export default function WorldMap({
|
|||||||
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
|
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('mousemove', FILL_LAYER, onHover);
|
||||||
map.off('mouseout', FILL_LAYER, onHoverOut);
|
map.off('mouseout', FILL_LAYER, onHoverOut);
|
||||||
map.off('click', FILL_LAYER, onClick);
|
map.off('click', FILL_LAYER, onClick);
|
||||||
@@ -646,6 +961,19 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
}, [regions, playerNationId, unowned]);
|
}, [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
|
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||||||
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||||||
// `position: relative`, which would otherwise win over a Tailwind class.
|
// `position: relative`, which would otherwise win over a Tailwind class.
|
||||||
|
|||||||
Reference in New Issue
Block a user