Compare commits

..
4 Commits
Author SHA1 Message Date
kamilkrzywda 7fbc00ebbe Merge pull request 'v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)' (#47) from feat/46-region-stats into master
CI / build + unit + e2e (push) Successful in 2m31s
2026-10-15 02:20:55 +02:00
kamilkrzywda 22805a3be6 v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)
CI / build + unit + e2e (pull_request) Successful in 2m34s
2026-10-15 00:17:19 +00:00
kamilkrzywda 327fffb789 Merge pull request 'v0.27.0 (#44): LLM-placed named map point markers' (#45) from feat/44-llm-map-pointers into master
CI / build + unit + e2e (push) Successful in 2m41s
2026-10-15 00:40:52 +02:00
kamilkrzywda a129c2ab81 v0.27.0 (#44): LLM-placed named map point markers
CI / build + unit + e2e (pull_request) Successful in 2m36s
- shared: 8 MarkerTypes + MARKER_TYPE_INFO (label/color/glyph); MapMarker;
  GameState.markers (required, [] at init); TurnResult.markers {add,remove};
  MarkerSpec/MarkerInfo; FNV-1a 4-char markerId; MAX_MARKERS=400.
- data: new public/region-centroids.json (1,800 anchors = largest-part centroid
  + spread ~0.1x min-bbox) built by new idempotent scripts/build-region-centroids.mjs.
- server: regions.ts centroid loader (public/ then dist/); engine applyTurnResult
  marker ops (remove -> owner-recompute -> add, jitter, cap, never throws);
  prompt documents the contract; buildUserPrompt carries markers; stub marker-free.
- client: WorldMap symbol layer rendered as canvas per-type icons (glyph CDN dead);
  hover tooltip shows name; click focuses owning nation.
- tests: 168 -> 189 (centroids script, regions centroids, db legacy, engine markers).
2026-10-14 22:37:29 +00:00
25 changed files with 19168 additions and 62 deletions
+89 -10
View File
@@ -56,7 +56,7 @@ history/
├── eslint.config.js # flat config (ESLint 9)
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 168 tests)
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
├── index.html
├── presets/ # 5 scenario JSON files + index.json (manifest)
@@ -64,28 +64,36 @@ history/
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
│ # Jump + NationOp confirm/reject request types + pure helpers
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
│ # seedRegionStats / migrateRegionStats)
├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3; per-region stats maintenance + derived recompute, v0.28 #46) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency/centroids/geo-manifest (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency, centroidOf/hasCentroids, geoOf/hasGeo (#46)
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
│ ├── presets.ts # load preset JSON files from disk
│ ├── db.ts # SQLite open/save/load
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
@@ -115,13 +123,15 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 168 tests) |
| `npm test` | vitest unit tests (`server/` + `scripts/`; 225 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
| `npm run build:region-geo` | (re)generate `public/region-geo.json` (region key → area km² + population) from `regions.geo.json` + `region-keys.json` + the committed `ne-admin0-pop.json` — deterministic + idempotent (byte-identical re-run; #46) |
| `npm run extract:admin0-pop` | one-time distillation of the 13 MB Natural Earth admin-0 source → the committed `public/ne-admin0-pop.json` (ISO_A3 → `POP_EST`, 249 codes); the raw source is never committed (#46) |
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
@@ -249,6 +259,74 @@ history/
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
(`server/engine.test.ts`).
- **Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.**
The LLM can place named **point markers** on the map each turn: eight `MarkerType`s
(`city, factory, army, port, capital, fleet, rocket, gathering`; a **capital** is at most one
per nation, a **gathering** a political/social mass event that can enable a non-military
takeover). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required,
default `[]`; `MarkerSpec = { type, name, regionKey }`). A marker's stable `id` is the FNV-1a
32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase
base-26 characters (mirroring the region-key scheme) — so a **re-add is idempotent** and a
**remove is unambiguous** (by id). `GameState.markers: MapMarker[]` is required (`[]` at game
creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
`applyTurnResult` handles markers **after** territory claims: (1) remove by id (absent id →
no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim
ownership** — a marker always follows its region (a captured region follows its new owner; a
region released to no man's land, or a key with 0 or 2+ owners, becomes `null`); (3) add — each
new spec is placed at its region's centroid (from `public/region-centroids.json`, injected via
the optional `centroidOf` param on `applyTurnResult`) plus a deterministic per-axis jitter
(`spread ≈ 0.1 × the largest part's smallest bbox dimension`, so multiple markers of one region
scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting
turn. Adds are **dropped, never thrown**, when the region key is unknown or has no centroid,
when the id already exists, or beyond the hard cap `MAX_MARKERS = 400`. The system prompt
documents the field + types + placement rules; `buildUserPrompt` carries the current markers
(as `MarkerInfo[]`) so the model can reference their ids in `remove`; a marker add/remove makes
a turn "meaningful". **Rendering is canvas-generated per-type ICON images, NOT text labels**
(`src/components/WorldMap.tsx`): every external glyph/font endpoint is a dead 404 (OpenFreeMap's
`fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`) and the OFM dark basemap only
renders because it is **raster** (labels baked into the tiles) — there is no working vector
font, so markers are drawn as self-contained, offline, deterministic per-type icons
(`drawMarkerIcon`: distinct shape per type, filled with the type color, outlined for legibility),
registered as named `ImageData` images that survive `styledata`. The marker **name shows in the
hover tooltip** (not a persistent on-map label), and **clicking a marker focuses its owning
nation's dossier**. There are **no separate marker event handlers** — MapLibre's per-layer event
delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead
`queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
unaffected and the marker path is unit-tested only (`server/engine.test.ts`,
`scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`).
- **Per-region stats — `GameState.regionStats`, derived `Nation.stats` (v0.28, #46; PR 1 of 2 — the LLM lever is PR 2).**
Since v0.28 the four 0–100 stats live at **region-key** granularity: `GameState.regionStats`
(required; `{}` at game creation) maps each owned region key to its own `RegionStats`, and
`Nation.stats` is **derived** — the **area×population-weighted median** of the stats of the
regions the nation owns (`deriveNationStats`, pure in `shared/protocol.ts`). A region's weight
is `(area>0?area:1)×(population>0?population:1)` from the committed `public/region-geo.json`
manifest (1,800 entries; area in km² at 2dp, integer population) — which is built by
`scripts/build-region-geo.mjs` from `regions.geo.json` + `region-keys.json` + the committed
`public/ne-admin0-pop.json` (Natural Earth admin-0 `POP_EST`, 249 codes, one-time distilled by
`scripts/extract-admin0-pop.mjs`; each country's `POP_EST` is spread over its sub-regions
proportional to area by largest-remainder, so per-country totals are preserved exactly). A
released / 0-owner / 2+-owner key has **no** entry. A fresh game is seeded by
`seedRegionStats` (every owned region gets a **copy** of its owner's stats — identical values,
so the derived median equals the original and fresh-game numbers are unchanged). Legacy SQLite
rows are migrated once on load by `migrateRegionStats` (`db.load`, called without `geoOf` —
weight-invariant for the identical-value seed): a row that already has `regionStats` (v0.28+)
is returned **unchanged** (never recomputed on load → no drift), a legacy row is seeded +
recomputed (0-region guard keeps its stats). `applyTurnResult` takes an optional `geoOf`
(passed through `runTurn`; the jump route wires it via `hasGeo() ? (key) => geoOf(key) :
undefined`) and maintains the table inside the **load-bearing order events → relations →
territory → stateChanges → recompute → markers → nationOps**: a release deletes the key's
entry; a (re-)claim re-inits an absent entry as a copy of the gainer's current `stats`;
nation-level `stateChanges` (the only LLM lever for now — the prompt is **unchanged** in this
PR) write to `n.stats` **and** to every region the nation owns; then every nation with ≥1
entered region gets `stats` recomputed as its regions' weighted median (a 0-region — or no-
entered-region — nation keeps its directly-maintained `stats`, which is why the existing unit
and e2e assertions pass unchanged; without the geo manifest the recompute degrades to an
unweighted median, never throwing). The stub never touches per-region stats (`LLM_STUB=1`
e2e unaffected), and the path is unit-tested (`server/engine.test.ts`,
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
when PR 2, the per-region LLM lever, ships).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
@@ -369,7 +447,8 @@ history/
(works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle.
- The `dist/` client is committed to the build output only; the `public/` data files
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
ownership/adjacency manifests — and `countries.geo.json`) are copied into `dist/` by Vite at
ownership/adjacency manifests — `region-centroids.json` — the marker anchors (#44) — and
`countries.geo.json`) are copied into `dist/` by Vite at
build time, so the prod image serves them from `dist/` (no separate copy). The server's region
loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
+36
View File
@@ -2,6 +2,42 @@
All notable changes to **History** are listed here, newest first.
## v0.28.0 — 2026-10-15 00:12
- **#46 (PR 1 of 2): per-region stats — a nation's four 0–100 stats become a *derived* aggregate.** Instead of storing `Nation.stats` as four independent per-nation numbers, they are now **derived** as the **area×population-weighted median** of the stats of the regions the nation owns. Each sub-region carries its own `RegionStats` in a new `GameState.regionStats: Record<regionKey, RegionStats>` table; a nation's displayed stats are the weighted median over its owned regions. This is the data foundation for the per-region LLM levers that land in **PR 2** — in this PR the LLM still only emits nation-level `stateChanges`, which are re-interpreted as applying to *all* of a nation's owned regions. A new `public/region-geo.json` manifest (area km² + population per region key) supplies the weighting. **Not deployed in this PR** — the production site updates when PR 2 ships (the change is additive and backward-compatible, so deferring the deploy is safe).
- **Data build (`scripts/`, turf-`.mjs` only; never imported by `.ts`).**
- `scripts/extract-admin0-pop.mjs` (`npm run extract:admin0-pop`) — **one-time** distillation of the 13 MB Natural Earth 1:10m admin-0 GeoJSON (a *source* file we do **not** commit) into the small committed `public/ne-admin0-pop.json` = `{ ADM0_A3 → max POP_EST }` (249 country codes, total 7,677,059,722). The 1:10m **admin-1** file the region generator uses carries no population, so the source is the **admin-0** `POP_EST` (a deliberate deviation, recorded here).
- `scripts/build-region-geo.mjs` (`npm run build:region-geo`) — builds `public/region-geo.json` = `{ regionKey → { area, population } }`: `area` = `@turf/area(finalFeature)/1e6` km² (2 dp); `population` = the parent country's `POP_EST` spread across its sub-regions **proportional to area via largest-remainder**, so every country's sub-regions sum **exactly** to its total (no rounding drift). Reads the committed `ne-admin0-pop.json` → fully reproducible in CI. **Deterministic + idempotent** (sorted keys; byte-identical re-run, pinned by the test). 1,800 entries; **total population 7,654,092,021** (the sum over the 177 repo countries).
- **Types (`shared/types.ts`, no runtime deps).** New `RegionStats = { military, economy, stability, population }` (0–100). `GameState.regionStats: Record<string, RegionStats>` is **required** (`{}` at game creation; a released / 0-owner / 2+owner region has **no** entry; a legacy SQLite row without the field is migrated on load, never wiped).
- **Pure helpers (`shared/protocol.ts`, no deps).**
- `weightedMedian(values, weights)` — sorts by value and returns the value at the first point the cumulative weight reaches half the total; non-finite/non-positive weights are excluded; empty / zero-total → 0. **Translation-invariant** (the median of identical values is that value — the invariant seeding and migration rely on).
- `deriveNationStats(regionKeys, regionStats, geoOf?)` — each stat is the weighted median over the nation's owned region keys; a region's weight is `(area>0?area:1)×(population>0?population:1)` (both fall back to 1 when the key has no geo entry, so missing geo degrades to a uniform median); a key with no `regionStats` entry is skipped; returns zero stats when no owned region has an entry (the caller's 0-region guard keeps `n.stats` in that case).
- `seedRegionStats(nations)` — seeds the table from nations: every owned key gets a **copy** of its owner's stats (first owner wins on a corrupt double-ownership), so a fresh nation's regions start identical (derived = seeded).
- `migrateRegionStats(state, geoOf?)` — **one-time** legacy migration: a row that already has `regionStats` is returned **unchanged** (no recompute on load → no drift); a legacy row (field absent) is seeded per region and each nation's stats recomputed (0-region guard). Never throws.
- **Server (`server/regions.ts`, turf-free).** Loads `region-geo.json` (probes `public/` then `dist/`) **optionally** — a missing/broken file degrades weighting to uniform (every region weight 1) while everything else keeps working. New `hasGeo()` and `geoOf(key)` accessors mirror the existing `hasCentroids`/`centroidOf`.
- **Server (`server/engine.ts`).** `applyTurnResult` gains an optional `geoOf` param and is reordered to: events → relations → **territory** → **stateChanges** → **recompute** → markers → nationOps.
- **Territory:** a **transferred** region **keeps its own** per-region stats (the conquest is what makes a nation's regions diverge); a **released** region's entry is **deleted** (no man's land carries none); a **re-claimed** region with no entry is **re-inited** to the gainer's current stats.
- **StateChanges (re-interpreted):** a nation-level `set`/`add` applies to `n.stats` **and** to every region the nation owns (clamped 0–100).
- **Recompute:** every nation's `stats` is re-derived via `deriveNationStats` (weighted by area×population when `geoOf` is present). Two guards: a **0-region** nation keeps its directly-maintained stats, and a nation **none of whose regions has an entry** keeps its stats too (avoids clobbering with zeros).
- `runTurn` threads the optional `geoOf` through to `applyTurnResult`.
- **Server (`server/index.ts`).** Game creation seeds `regionStats` via `seedRegionStats` **after** `expandCountriesToKeys` (so regions hold real keys) — a new game's derived stats equal its nation-level stats on turn 1. The `jump` route passes `geoOf: hasGeo() ? geoOf : undefined`.
- **Server (`server/db.ts`).** `load()` migrates a legacy row (regionStats absent) via `migrateRegionStats(state)` **without** `geoOf` (uniform weights — weight-invariant for the equal-value seeding, and avoids a `db`→`regions` import). v0.28+ rows load unchanged.
- **Prompt / client / stub unchanged.** `nationForPrompt`/`buildUserPrompt` still surface the (now derived) four stats; there is no per-region LLM lever yet (PR 2). The deterministic stub is unaffected (nation-level `stateChanges` flow through the re-interpretation; the median of identical values is that value, so e2e stat assertions hold).
- **Tests (189 → 225).** New `server/protocol.test.ts` (weightedMedian incl. translation-invariance / identical / zero / empty; deriveNationStats weighted vs uniform / skip-missing / zero / single / fallback-weight; seedRegionStats; migrateRegionStats v0.28+ no-op vs legacy / 0-region); `server/engine.test.ts` (transfer keeps region stats + aggregate blends, release deletes entry, re-claim re-inits, 0-region keeps stats, geoOf-weighted vs uniform recompute, nation-level change re-applies to regions, original not mutated); `server/db.test.ts` (legacy row → seeded + preserved stats; v0.28+ row loads unchanged); `server/regions.test.ts` (`hasGeo`/`geoOf` in dev + prod-`dist/` layouts, graceful degradation when absent); new `scripts/region-geo.test.ts` (1,800-entry key-set match, sorted, finite / ≤2dp area / integer population, total 7,654,092,021, per-country totals preserved vs admin-0, spot-checked values, byte-identical re-run).
- **Verification.** Typecheck, lint, **225 unit tests**, and the production build all pass (the build copies `region-geo.json` + `ne-admin0-pop.json` into `dist/` like the other manifests, so the prod container layout is covered). e2e is verified by CI on the PR (no local run).
- **Deploy:** **none** in this PR — the production site (`https://history.dev.kkhost.pl`) updates when **PR 2** ships. The change is additive + backward-compatible, so existing games migrate on load and no data wipe is needed.
## v0.27.0 — 2026-10-14 22:34
- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer.
- **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`.
- **Stable ids (`shared/protocol.ts`).** A marker's `id` is the FNV-1a 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase base-26 characters (mirroring the region-key scheme). This makes a **re-add idempotent** (the same type+name+region never creates a second marker) and a **remove unambiguous** (by id).
- **Centroid data (`public/region-centroids.json`, new).** ~1,800 entries keyed identically to `region-keys.json`, each `{ lat, lon, spread }`: the anchor is the **centroid of the region's largest-area part** (a single representative point, not an average across disjoint parts), and `spread` ≈ **0.1 × that part's smallest bbox dimension** (in degrees). A new idempotent pipeline stage `scripts/build-region-centroids.mjs` (`npm run build:region-centroids`) regenerates it deterministically (byte-identical re-run, sha256-pinned by the test). The engine uses the centroid as the marker's base position and the `spread` to jitter multiple markers of one region around it (a deterministic per-axis FNV-1a-derived offset, so markers scatter instead of stacking).
- **Engine (`server/engine.ts`).** `applyTurnResult` processes markers **after** territory claims, in three steps: (1) remove by id (a listed-but-absent id is a no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim ownership** — a captured region follows its new owner, a region released to no man's land (or a key with 0 or 2+ owners) becomes `null` (a marker always follows its region); (3) add — each new spec is placed at its region's centroid (injected via a new optional `centroidOf` param) plus the per-axis jitter, owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped (never thrown) when their region key is unknown / has no centroid, when the id already exists (idempotent re-add), or beyond the hard cap `MAX_MARKERS = 400` (later adds dropped). The system prompt documents the field, the eight types, and the placement rules; `buildUserPrompt` carries the current markers (as `MarkerInfo[]`) so the model can reference their stable ids in `remove`; `isMeaningful` treats a marker add/remove as a real world change.
- **Client (`src/components/WorldMap.tsx`).** A second GeoJSON source + a symbol layer sits above the choropleth. **Deliberate deviation from the original "name as a text label" design:** every external glyph endpoint is a dead 404 (OpenFreeMap's `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`), and the OFM dark basemap only renders at all because it is **raster** (labels baked into the PNG tiles) — there is no working vector font. So markers are drawn as **self-contained, offline, canvas-generated per-type icons** (`drawMarkerIcon`: a distinct shape per type — circle / rounded-square / triangle / diamond / star / center-dotted circles / up-arrow — filled with the type color, outlined for legibility on the dark basemap), registered as named `ImageData` images that survive `styledata`. The marker **name is shown in the hover tooltip** (region name + owner chip + marker name) instead of a persistent on-map label, and **clicking a marker focuses its owning nation's dossier** (a marker's owner is its region's owner). There are no separate marker event handlers: MapLibre's per-layer event delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
- **Stub (`server/stub.ts`) stays marker-free** — it returns `markers: { add: [], remove: [] }` — so the e2e suite (CI, `LLM_STUB=1`) is unaffected and fully deterministic; the full marker path is covered by unit tests only.
- **Tests (168 → 189).** New `scripts/region-centroids.test.ts` (1,800 entries, key-set match, finite/world-bounds, each centroid inside its feature's bbox via turf-free coordinate iteration, spot-checked anchors, byte-identical re-run determinism); `server/regions.test.ts` (`hasCentroids`/`centroidOf` in dev + prod-`dist/` layouts, graceful degradation when absent); `server/db.test.ts` (legacy row → `markers: []`); and an `engine.test.ts` marker suite (valid add → id/position/owner/placedTurn, idempotent re-add, region-validity/centroid drops, the 400 cap, remove by id / no-op, owner-follows-region incl. release→null and re-claim, `buildUserPrompt`/system-prompt contract, `validateMarkerOps` raw cleaner, and the `runTurn` centroid wiring).
- **Verification.** Typecheck, lint, **189 unit tests**, and the production build all pass (the build copies `region-centroids.json` into `dist/` like the other manifests, so it works in the prod container layout).
- **Deploy:** manual `docker compose up -d --build` on the host — **no `down -v`** (markers are additive; legacy games load with `markers: []`).
## v0.26.0 — 2026-10-14 19:24
- **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into:
- `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border).
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.26.0",
"version": "0.28.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
@@ -18,6 +18,9 @@
"split:regions:band": "node scripts/split-arkhangel-band.mjs",
"build:region-keys": "node scripts/build-region-keys.mjs",
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
"build:region-centroids": "node scripts/build-region-centroids.mjs",
"build:region-geo": "node scripts/build-region-geo.mjs",
"extract:admin0-pop": "node scripts/extract-admin0-pop.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"e2e": "playwright test"
+251
View File
@@ -0,0 +1,251 @@
{
"ABW": 106314,
"AFG": 38041754,
"AGO": 31825295,
"AIA": 14731,
"ALB": 2854191,
"ALD": 29884,
"AND": 77142,
"ARE": 9770529,
"ARG": 44938712,
"ARM": 2957731,
"ASM": 55312,
"ATA": 4490,
"ATF": 140,
"ATG": 97118,
"AUS": 25364307,
"AUT": 8877067,
"AZE": 10023318,
"BDI": 11530580,
"BEL": 11484055,
"BEN": 11801151,
"BFA": 20321378,
"BGD": 163046161,
"BGR": 6975761,
"BHR": 1641172,
"BHS": 389482,
"BIH": 3301000,
"BLM": 9961,
"BLR": 9466856,
"BLZ": 390353,
"BMU": 63918,
"BOL": 11513100,
"BRA": 211049527,
"BRB": 287025,
"BRN": 433285,
"BTN": 763092,
"BWA": 2303697,
"CAF": 4745185,
"CAN": 37589262,
"CHE": 8574832,
"CHL": 18952038,
"CHN": 1397715000,
"CIV": 25716544,
"CMR": 25876380,
"CNM": 10000,
"COD": 86790567,
"COG": 5380508,
"COK": 17459,
"COL": 50339443,
"COM": 850886,
"CPV": 549935,
"CRI": 5047561,
"CSI": 4,
"CUB": 11333483,
"CUW": 157538,
"CYM": 64948,
"CYN": 326000,
"CYP": 1198575,
"CZE": 10669709,
"DEU": 83132799,
"DJI": 973560,
"DMA": 71808,
"DNK": 5818553,
"DOM": 10738958,
"DZA": 43053054,
"ECU": 17373662,
"EGY": 100388073,
"ERI": 6081196,
"ESB": 7850,
"ESP": 47076781,
"EST": 1326590,
"ETH": 112078730,
"FIN": 5520314,
"FJI": 889953,
"FLK": 3398,
"FRA": 67059887,
"FRO": 48678,
"FSM": 113815,
"GAB": 2172579,
"GBR": 66834405,
"GEO": 3720382,
"GGY": 62792,
"GHA": 30417856,
"GIB": 33701,
"GIN": 12771246,
"GMB": 2347706,
"GNB": 1920922,
"GNQ": 1355986,
"GRC": 10716322,
"GRD": 112003,
"GRL": 56225,
"GTM": 16604026,
"GUM": 167294,
"GUY": 782766,
"HKG": 7507400,
"HND": 9746117,
"HRV": 4067500,
"HTI": 11263077,
"HUN": 9769949,
"IDN": 270625568,
"IMN": 84584,
"IND": 1366417754,
"IOA": 2387,
"IOT": 3000,
"IRL": 4941444,
"IRN": 82913906,
"IRQ": 39309783,
"ISL": 361313,
"ISR": 9053300,
"ITA": 60297396,
"JAM": 2948279,
"JEY": 107800,
"JOR": 10101694,
"JPN": 126264931,
"KAB": 36175,
"KAS": 6000,
"KAZ": 18513930,
"KEN": 52573973,
"KGZ": 6456900,
"KHM": 16486542,
"KIR": 117606,
"KNA": 52834,
"KOR": 51709098,
"KOS": 1794248,
"KWT": 4207083,
"LAO": 7169455,
"LBN": 6855713,
"LBR": 4937374,
"LBY": 6777452,
"LCA": 182790,
"LIE": 38019,
"LKA": 21803000,
"LSO": 2125268,
"LTU": 2786844,
"LUX": 619896,
"LVA": 1912789,
"MAC": 640445,
"MAF": 38002,
"MAR": 36471769,
"MCO": 38964,
"MDA": 2657637,
"MDG": 26969307,
"MDV": 530953,
"MEX": 127575529,
"MHL": 58791,
"MKD": 2083459,
"MLI": 19658031,
"MLT": 502653,
"MMR": 54045420,
"MNE": 622137,
"MNG": 3225167,
"MNP": 57216,
"MOZ": 30366036,
"MRT": 4525696,
"MSR": 4649,
"MUS": 1265711,
"MWI": 18628747,
"MYS": 31949777,
"NAM": 2494530,
"NCL": 287800,
"NER": 23310715,
"NFK": 2169,
"NGA": 200963599,
"NIC": 6545502,
"NIU": 1620,
"NLD": 17332850,
"NOR": 5347896,
"NPL": 28608710,
"NRU": 12581,
"NZL": 4917000,
"OMN": 4974986,
"PAK": 216565318,
"PAN": 4246439,
"PCN": 54,
"PER": 32510453,
"PGA": 100,
"PHL": 108116615,
"PLW": 18008,
"PNG": 8776109,
"POL": 37970874,
"PRI": 3193694,
"PRK": 25666161,
"PRT": 10269417,
"PRY": 7044636,
"PSX": 4685306,
"PYF": 279287,
"QAT": 2832067,
"ROU": 19356544,
"RUS": 144373535,
"RWA": 12626950,
"SAH": 603253,
"SAU": 34268528,
"SDN": 42813238,
"SDS": 11062113,
"SEN": 16296364,
"SGP": 5703569,
"SGS": 30,
"SHN": 4534,
"SLB": 669823,
"SLE": 7813215,
"SLV": 6453553,
"SMR": 33860,
"SOL": 5096159,
"SOM": 10192317,
"SPM": 5997,
"SRB": 6944975,
"STP": 215056,
"SUR": 581363,
"SVK": 5454073,
"SVN": 2087946,
"SWE": 10285453,
"SWZ": 1148130,
"SXM": 40733,
"SYC": 97625,
"SYR": 17070135,
"TCA": 38191,
"TCD": 15946876,
"TGO": 8082366,
"THA": 69625582,
"TJK": 9321018,
"TKM": 5942089,
"TLS": 1293119,
"TON": 104494,
"TTO": 1394973,
"TUN": 11694719,
"TUR": 83429615,
"TUV": 11646,
"TWN": 23568378,
"TZA": 58005463,
"UGA": 44269594,
"UKR": 44385155,
"UMI": 300,
"URY": 3461734,
"USA": 328239523,
"USG": 6000,
"UZB": 33580650,
"VAT": 825,
"VCT": 110589,
"VEN": 28515829,
"VGB": 30030,
"VIR": 106631,
"VNM": 96462106,
"VUT": 299882,
"WLF": 11558,
"WSB": 7850,
"WSM": 197097,
"YEM": 29161922,
"ZAF": 58558270,
"ZMB": 17861030,
"ZWE": 14645468
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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`,
);
+165
View File
@@ -0,0 +1,165 @@
/**
* scripts/build-region-geo.mjs — build the region **geo** manifest:
* `public/region-geo.json` = `{ "<regionKey>": { "area": <number>, "population": <number> }, ... }`.
*
* For every sub-region (region key, see `public/region-keys.json`) this records
* two facts the v0.28 per-region stats model (and its area×population weighting)
* needs:
* - `area` — the region's **total** surface area in km², from
* `@turf/area` on the FINAL generated feature
* (`public/regions.geo.json`) — turf reports square metres,
* so the raw value is divided by 1e6 — rounded to 2 decimals.
* - `population` — the region's share of its **parent country's** real
* population (Natural Earth 1:10m admin-0 `POP_EST`, from the
* committed `public/ne-admin0-pop.json`), distributed across
* the country's sub-regions **proportional to each region's
* area** (a first-order "uniform population density within a
* country" approximation — Natural Earth admin-1, which the
* region generator uses, carries no per-unit population).
*
* Population distribution uses **largest remainder** per country, so the sum of
* a country's sub-region populations is EXACTLY the country's POP_EST, and the
* sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries'
* POP_EST (7,654,092,021) — no rounding drift.
*
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
* v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json`
* (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from
* the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build
* is fully reproducible in CI.
*
* Output contract (asserted by scripts/region-geo.test.ts):
* - one entry for **every** region key, keyed identically to
* `public/region-keys.json` (same key set, 1,800 entries);
* - `area` is a finite number >= 0 (km², <= 2 decimals);
* - `population` is a non-negative integer;
* - the total population across all entries is a non-negative integer.
*
* Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and
* the committed population table), pure turf, largest-remainder with a
* (remainder desc, key asc) tie-break, and stable sorted key order → a re-run
* rewrites a byte-identical file (the test re-runs this script and compares
* bytes).
*
* Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import area from '@turf/area';
import { feature } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const round2 = (v) => Math.round(v * 100) / 100;
// --- load the inputs (plain parse) ------------------------------------------
const geoPath = path.join(root, 'public', 'regions.geo.json');
const keysPath = path.join(root, 'public', 'region-keys.json');
const popPath = path.join(root, 'public', 'ne-admin0-pop.json');
let geo, idToKey, popByCountry;
try {
geo = JSON.parse(readFileSync(geoPath, 'utf8'));
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
popByCountry = JSON.parse(readFileSync(popPath, 'utf8'));
} catch (err) {
console.error(
`[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` +
`Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`,
);
throw err;
}
/**
* One pass over the geo features: for each region key record its (unrounded)
* turf area and its parent ISO_A3, then group the keys by parent so the
* population can be distributed per country.
*/
const regionArea = new Map(); // key -> unrounded area (km²)
const regionParent = new Map(); // key -> parent ISO_A3
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
// @turf/area reports square METRES; the manifest stores km².
const a = area(feature(f.geometry)) / 1e6;
regionArea.set(key, a);
regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : '');
}
// Group region keys by parent country (file order is stable, but we sort for
// deterministic population distribution).
const keysByCountry = new Map(); // parent -> string[] of keys
for (const [key, parent] of regionParent) {
if (!parent) continue; // a region with no parent has no population source
const list = keysByCountry.get(parent);
if (list) list.push(key);
else keysByCountry.set(parent, [key]);
}
const population = new Map(); // key -> integer population
for (const [parent, keys] of keysByCountry) {
keys.sort(); // deterministic ordering for the tie-break
const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0;
const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0);
if (P <= 0 || totalArea <= 0) {
for (const k of keys) population.set(k, 0);
continue;
}
// Largest remainder: floor each share, then hand out the leftover 1s to the
// regions with the largest fractional remainders (ties broken by key asc).
const shares = keys.map((k) => {
const a = regionArea.get(k) || 0;
const ideal = (P * a) / totalArea;
return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) };
});
const distributed = shares.reduce((s, x) => s + x.floor, 0);
let deficit = P - distributed; // 0 <= deficit < keys.length
const order = shares
.map((x, i) => ({ i, rem: x.rem, k: x.k }))
.sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
for (let i = 0; i < order.length && deficit > 0; i++) {
shares[order[i].i].floor += 1;
deficit -= 1;
}
for (const x of shares) population.set(x.k, x.floor);
}
// --- assemble the manifest (sorted keys) -------------------------------------
const expectedKeys = Object.values(idToKey);
const manifest = {};
for (const k of [...expectedKeys].sort()) {
const a = regionArea.get(k);
const p = population.get(k);
if (a === undefined || p === undefined) {
throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`);
}
manifest[k] = { area: round2(a), population: p };
}
// --- hard assertions (the test re-checks these against the committed file) ----
if (Object.keys(manifest).length !== expectedKeys.length) {
throw new Error(
`expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`,
);
}
let totalPop = 0;
let zeros = 0;
for (const [k, v] of Object.entries(manifest)) {
if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`);
if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`);
if (v.area === 0) zeros += 1;
totalPop += v.population;
}
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n');
// --- summary ------------------------------------------------------------------
console.log(
`[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` +
`${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`,
);
+78
View File
@@ -0,0 +1,78 @@
/**
* scripts/extract-admin0-pop.mjs — one-time extraction of the population
* SOURCE for the region-geo manifest: `public/ne-admin0-pop.json`.
*
* Output = `{ "<ADM0_A3>": <POP_EST>, ... }` — the estimated population
* (POP_EST) of every Natural Earth 1:10m **admin-0 country** code, keeping
* the **maximum** POP_EST seen for each code (Natural Earth ships a few
* duplicated / broken-out features per code, e.g. the `TLC` stubs, whose
* POP_EST is 0; the real country always wins by having the larger value).
* Only codes with a finite, positive POP_EST are recorded (a 0/null POP_EST
* has nothing to distribute). Keys are sorted; the file is written with
* 2-space indent + trailing newline (deterministic, committed).
*
* Why a separate committed file: `public/region-geo.json` (built by
* `build-region-geo.mjs`) needs a per-country population, but the raw
* Natural Earth GeoJSON (13 MB) is a *source* file we deliberately do NOT
* commit (see the AGENTS.md data-pipeline note). This script distils it
* into a tiny (a few KB) derived table that IS committed, so the region-geo
* builder stays fully reproducible in CI without the source present.
* Re-run only when the underlying Natural Earth data changes.
*
* The 1:10m admin-1 file (the one the region generator uses) carries NO
* population; the 1:10m admin-0 file does (uppercase `POP_EST` / `ADM0_A3`).
*
* Usage:
* NE_ADMIN0=/path/to/ne_10m_admin_0_countries.geojson node scripts/extract-admin0-pop.mjs
* (default source: /tmp/ne10m_admin0.geojson)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const src = process.env.NE_ADMIN0 || '/tmp/ne10m_admin0.geojson';
let raw;
try {
raw = readFileSync(src, 'utf8');
} catch (err) {
console.error(
`[extract-admin0-pop] could not read the Natural Earth admin-0 source at ${src}. ` +
`Download it (ne_10m_admin_0_countries.geojson) and re-run with NE_ADMIN0 pointing at it.`,
);
throw err;
}
const geo = JSON.parse(raw);
/** ADM0_A3 -> max finite, positive POP_EST. */
const byCode = new Map();
for (const f of geo.features ?? []) {
const p = f.properties ?? {};
const code = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : '';
const pop = typeof p.POP_EST === 'number' ? p.POP_EST : NaN;
if (!code || !Number.isFinite(pop) || pop <= 0) continue;
const prev = byCode.get(code);
if (prev === undefined || pop > prev) byCode.set(code, Math.round(pop));
}
if (byCode.size === 0) {
throw new Error('[extract-admin0-pop] no finite positive POP_EST found in the source — wrong file?');
}
// Deterministic: sorted keys, 2-space indent, trailing newline.
const keys = [...byCode.keys()].sort();
const manifest = {};
for (const k of keys) manifest[k] = byCode.get(k);
const out = path.join(root, 'public', 'ne-admin0-pop.json');
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
let sum = 0;
for (const v of byCode.values()) sum += v;
console.log(
`[extract-admin0-pop] wrote public/ne-admin0-pop.json — ${byCode.size} country codes, ` +
`total population ${sum.toLocaleString('en-US')} (Natural Earth 1:10m admin-0, max POP_EST per code)`,
);
+158
View File
@@ -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);
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* scripts/region-geo.test.ts — validation for the committed
* `public/region-geo.json` (built by `scripts/build-region-geo.mjs`).
*
* The manifest maps each region key to its { area (km²), population } — the
* weighting source for the per-region stats model (v0.28, #46): a nation's
* four 0–100 stats are derived as the area×population-weighted median of the
* stats of the regions it owns. It is a load-bearing data artifact — this
* test pins its contract.
*
* The `area` is the turf area of the region's final geometry (km², 2 dp); the
* `population` is the country's Natural-Earth admin-0 `POP_EST` (from the
* committed `public/ne-admin0-pop.json`) distributed across its sub-regions
* proportional to area via largest-remainder, so every country's sub-regions
* sum EXACTLY to the country's total (and the global total is the sum over the
* 177 repo countries). This is a dependency-free test: it reads the JSON with
* plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull
* `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the
* v0.17 note). The determinism test re-runs the (turf `.mjs`) builder and
* compares bytes, matching that the builder is idempotent.
*/
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
interface GeoEntry {
area: number;
population: number;
}
// The 177 repo countries' admin-0 population totals (the committed derived
// source). Only the countries actually present in the region dataset appear.
const admin0 = readJson('public/ne-admin0-pop.json') as Record<string, number>;
// feature id -> region key
const manifest = readJson('public/region-keys.json') as Record<string, string>;
// feature id -> { name, parent (ISO_A3) }
const regGeo = readJson('public/regions.geo.json') as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
// region key -> parent ISO_A3 (via the key manifest + the geo feature parent).
const keyToParent: Record<string, string> = {};
for (const f of regGeo.features ?? []) {
const key = manifest[String(f.id)];
const parent = f.properties?.parent;
if (key && parent) keyToParent[key] = parent;
}
const geo = readJson('public/region-geo.json') as Record<string, GeoEntry>;
const keys = Object.values(manifest);
describe('region-geo.json', () => {
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
expect(new Set(Object.keys(geo))).toEqual(new Set(keys));
expect(Object.keys(geo)).toHaveLength(1800);
});
it('is keyed in sorted (deterministic) order', () => {
const ks = Object.keys(geo);
expect(ks).toEqual([...ks].sort());
});
it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => {
for (const [k, v] of Object.entries(geo)) {
expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true);
expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true);
expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true);
// area is recorded to at most 2 decimal places (km²)
expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true);
expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true);
expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true);
}
});
it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => {
// The distribution preserved each country's total exactly, so the grand
// total over all sub-regions is the sum over the repo countries only.
const total = Object.values(geo).reduce((s, v) => s + v.population, 0);
expect(total).toBe(7654092021);
});
it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => {
// For every parent country present in the region dataset, the sum of its
// sub-regions' populations must equal that country's admin-0 POP_EST
// EXACTLY — this is the invariant the largest-remainder distribution
// guarantees (no population is created or lost by the split).
const byParent: Record<string, number> = {};
for (const [k, v] of Object.entries(geo)) {
const p = keyToParent[k];
if (!p) throw new Error(`no parent for region key ${k}`);
byParent[p] = (byParent[p] ?? 0) + v.population;
}
expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries
for (const [p, sum] of Object.entries(byParent)) {
expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined();
expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]);
}
});
it('spot-checks well-known regions for exact area + population', () => {
const is = (k: string, area: number, population: number) => {
const v = geo[k];
expect(v, `missing geo for ${k}`).toBeDefined();
expect(v!.area, `area for ${k}`).toBe(area);
expect(v!.population, `population for ${k}`).toBe(population);
};
is('california-kikh', 413329.21, 14184195); // US-CA
is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core
is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41)
is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41)
is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31)
is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31)
is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain)
});
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
const file = path.join(root, 'public', 'region-geo.json');
const before = readFileSync(file, 'utf8');
// The builder is cwd-independent (it resolves the repo root from its own
// location) and idempotent: same input files (regions.geo.json +
// region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding
// + stable (sorted) key order → identical bytes.
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], {
cwd: root,
stdio: 'pipe',
});
const after = readFileSync(file, 'utf8');
expect(after).toBe(before);
});
});
+50
View File
@@ -25,6 +25,12 @@ function makeState(id: string): GameState {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) a v0.28+ save carries per-region stats; the fake 'GBR'
// region here holds a copy of the uk's (single-region) stats.
regionStats: {
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
},
};
}
@@ -97,6 +103,50 @@ describe('openDb', () => {
db.close();
});
it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.markers).toEqual([]);
db.close();
});
it('(v0.28 #46) a legacy row saved before the regionStats field existed loads with it seeded per region and stats recomputed', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { regionStats?: Record<string, unknown> }).regionStats; // a pre-v0.28 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
// each owned region is seeded with a copy of its owner's stats…
expect(loaded!.regionStats).toEqual({
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
});
// …and a single-region nation's derived stats equal the seeded ones
// (the weighted median of identical values is that value).
expect(loaded!.nations.uk.stats).toEqual({ military: 80, economy: 70, stability: 60, population: 50 });
db.close();
});
it('(v0.28 #46) a v0.28+ row (regionStats present) loads UNCHANGED — no recompute on load', () => {
const db = openDb(':memory:');
const s = makeState('current');
// Diverge one region's stats from the nation level to prove load does not
// recompute (a v0.28+ save keeps its stored derived stats exactly).
s.regionStats = { GBR: { military: 99, economy: 70, stability: 60, population: 50 } };
s.nations = { uk: { ...s.nations.uk, stats: { military: 42, economy: 70, stability: 60, population: 50 } } };
db.save(s);
const loaded = db.load('current');
expect(loaded).not.toBeNull();
expect(loaded!.regionStats).toEqual({ GBR: { military: 99, economy: 70, stability: 60, population: 50 } });
// the nation's stats are NOT recomputed from the region on load (they stay 42)
expect(loaded!.nations.uk.stats.military).toBe(42);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+11 -1
View File
@@ -2,6 +2,7 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
export interface Db {
@@ -39,13 +40,22 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
try {
const state = JSON.parse(row.data) as GameState;
let state = JSON.parse(row.data) as GameState;
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
// with it defaulted: a legacy game has no no-man's-land regions.
if (state.unowned === undefined) state.unowned = [];
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
// has none awaiting confirm/reject.
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
// nation-level stats and the nations' stats are recomputed as the
// (uniform-weight) area×population-weighted median, so no data is lost
// and a single-region nation keeps exactly its old stats.
state = migrateRegionStats(state);
return state;
} catch {
return null;
+493 -22
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub';
import {
@@ -21,6 +21,7 @@ import {
removeRoundItem,
resolveNationRef,
runTurn,
validateMarkerOps,
validateNationOps,
validateTurnResult,
} from './engine';
@@ -70,6 +71,11 @@ const preset: Preset = {
};
function baseState(): GameState {
const nations: GameState['nations'] = {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
};
return {
id: 'g1',
presetId: 'test',
@@ -77,16 +83,16 @@ function baseState(): GameState {
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) every owned region starts as a copy of its owner's stats,
// so a fresh state's derived stats equal its nation-level stats.
regionStats: seedRegionStats(nations),
};
}
@@ -524,7 +530,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -545,7 +551,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -565,7 +571,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -583,7 +589,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
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' }]);
});
@@ -1039,7 +1045,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1055,7 +1061,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1077,7 +1083,7 @@ describe('applyTurnResult', () => {
// 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: [] }],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
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
@@ -1102,7 +1108,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1119,7 +1125,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
stateChanges: [],
relations: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1208,7 +1214,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
nationOps: [], markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1323,7 +1329,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
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);
});
@@ -1630,9 +1636,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
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: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false);
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: [], markers: { add: [], remove: [] } })).toBe(true);
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)', () => {
@@ -1644,8 +1650,473 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
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);
});
});
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
});
});
// --- Per-region stats (v0.28, #46): the derived-stats model ------------------
//
// A nation's 0–100 stats are DERIVED as the area×population-weighted median of
// the stats of the regions it owns (`GameState.regionStats`). These tests pin
// the turn-level maintenance of that model in `applyTurnResult`:
// - a transferred region keeps its OWN stats (the conquest is what makes a
// nation's regions diverge);
// - a released region's entry is deleted (no man's land carries none);
// - a re-claimed region with no entry is re-inited to the gainer's stats;
// - a 0-region nation keeps its (directly-maintained) stats;
// - the end-of-turn recompute weights by area×population when `geoOf` is
// provided (and degrades to a uniform median without it).
//
// `applyTurnResult` is used directly (not `runTurn`) so the tests are pure and
// don't depend on the LLM: the result objects below are constructed directly.
const NS = (military: number, economy: number, stability: number, population: number): NationStats => ({
military,
economy,
stability,
population,
});
const RS = (military: number, economy: number, stability: number, population: number): RegionStats => ({
military,
economy,
stability,
population,
});
function mkNation(id: string, stats: NationStats, territory: string[]): Nation {
return { id, name: id.toUpperCase(), type: 'major', stats, territory, relations: {} };
}
function mkTurnState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
unowned: string[] = [],
): GameState {
return {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: Object.keys(nations)[0] ?? '',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned,
pendingNationOps: [],
markers: [],
regionStats,
};
}
function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
return {
date: '1939-09-03',
events: partial.events ?? [],
stateChanges: partial.stateChanges ?? [],
relations: partial.relations ?? [],
territoryChanges: partial.territoryChanges ?? [],
unowned: partial.unowned ?? [],
nationOps: partial.nationOps ?? [],
markers: partial.markers ?? { add: [], remove: [] },
};
}
describe('applyTurnResult — per-region stats (v0.28, #46)', () => {
it('a transferred region keeps its OWN stats; the conqueror\'s aggregate blends them', () => {
// uk owns g1 (military 80), de owns d1 (military 95). de conquers g1.
const state = mkTurnState(
{
uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']),
de: mkNation('de', NS(95, 90, 75, 90), ['d1']),
},
{
g1: RS(80, 70, 60, 50),
d1: RS(95, 90, 75, 90),
},
);
const next = applyTurnResult(
state,
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }),
);
// territory moved
expect(next.nations.uk.territory).toEqual([]);
expect(next.nations.de.territory).toEqual(['d1', 'g1']);
// the transferred region KEPT its own (uk's) stats — not re-inited to de's
expect(next.regionStats.g1).toEqual(RS(80, 70, 60, 50));
expect(next.regionStats.d1).toEqual(RS(95, 90, 75, 90));
// de's derived stats are the (uniform) median of its two regions → pulled
// toward the conquered region's lower values
expect(next.nations.de.stats).toEqual(RS(80, 70, 60, 50));
// uk (now 0 regions) keeps its directly-maintained stats untouched
expect(next.nations.uk.stats).toEqual(NS(80, 70, 60, 50));
});
it('a released region leaves no-man\'s land and its stats entry is deleted', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(80, 70, 60, 50), ['g1', 'g2']) },
{ g1: RS(80, 70, 60, 50), g2: RS(60, 60, 60, 60) },
);
const next = applyTurnResult(state, mkTurnResult({ unowned: ['g1'] }));
expect(next.unowned).toEqual(['g1']);
expect(next.nations.uk.territory).toEqual(['g2']);
// g1's stats left the table with the region; g2's stay
expect(next.regionStats).toEqual({ g2: RS(60, 60, 60, 60) });
// uk's derived stats now come only from g2
expect(next.nations.uk.stats).toEqual(RS(60, 60, 60, 60));
});
it('a re-claimed region with no entry is re-inited to the gainer\'s current stats', () => {
// g1 is unowned (released earlier; its stats entry was deleted). de owns d1.
const deStats = NS(95, 90, 75, 90);
const state = mkTurnState(
{
uk: mkNation('uk', NS(80, 70, 60, 50), []),
de: mkNation('de', deStats, ['d1']),
},
{ d1: RS(95, 90, 75, 90) }, // g1 has NO entry (it was released)
['g1'],
);
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }));
expect(next.unowned).toEqual([]); // g1 leaves no man's land
expect(next.nations.de.territory).toEqual(['d1', 'g1']);
// g1 was re-inited as a copy of de's (pre-stateChanges) stats
expect(next.regionStats.g1).toEqual(RS(95, 90, 75, 90));
// both of de's regions now hold identical stats → derived stats = those stats
expect(next.nations.de.stats).toEqual(RS(95, 90, 75, 90));
});
it('a 0-region nation keeps its directly-maintained stats (the recompute skips it)', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(80, 70, 60, 50), []) },
{},
);
const next = applyTurnResult(
state,
mkTurnResult({ stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }] }),
);
expect(next.nations.uk.territory).toEqual([]);
// no regions to aggregate → the nation-level update sticks
expect(next.nations.uk.stats.military).toBe(99);
expect(next.regionStats).toEqual({});
});
it('the recompute weights by area×population: the larger region dominates (geoOf)', () => {
// uk owns two regions: a small one (military 50) and a large one (military
// 90). With area×population weighting the large region's value wins; without
// geo the uniform median picks the lower value (reached first at half-weight).
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['big', 'small']) },
{ big: RS(90, 90, 90, 90), small: RS(50, 50, 50, 50) },
);
const geoOf = (k: string) =>
k === 'big' ? { area: 1000, population: 1000 } : { area: 10, population: 100 };
const weighted = applyTurnResult(state, mkTurnResult({}), undefined, undefined, geoOf);
expect(weighted.nations.uk.stats).toEqual(RS(90, 90, 90, 90)); // the large region dominates
const uniform = applyTurnResult(state, mkTurnResult({})); // no geoOf
expect(uniform.nations.uk.stats).toEqual(RS(50, 50, 50, 50)); // uniform → lower value first
});
it('a nation-level stat change re-applies to every owned region (before the recompute)', () => {
// uk owns two identical regions. A nation-level `add` applies to each region
// and the derived (uniform) median tracks it; a 0-region sibling keeps its
// own nation-level update.
const state = mkTurnState(
{
uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']),
de: mkNation('de', NS(50, 50, 50, 50), []),
},
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({
stateChanges: [
{ nation: 'uk', stat: 'economy', op: 'add', value: 20 },
{ nation: 'de', stat: 'military', op: 'set', value: 77 },
],
}),
);
// uk: both regions +20 economy → derived economy 70
expect(next.regionStats.a).toEqual(RS(50, 70, 50, 50));
expect(next.regionStats.b).toEqual(RS(50, 70, 50, 50));
expect(next.nations.uk.stats).toEqual(RS(50, 70, 50, 50));
// de: 0 regions → its nation-level set sticks
expect(next.nations.de.stats.military).toBe(77);
});
it('the original state is never mutated', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(80, 70, 60, 50), ['g1']), de: mkNation('de', NS(95, 90, 75, 90), ['d1']) },
{ g1: RS(80, 70, 60, 50), d1: RS(95, 90, 75, 90) },
);
const beforeNations = JSON.stringify(state.nations);
const beforeRegions = JSON.stringify(state.regionStats);
applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['g1'], lose: [] }] }));
expect(JSON.stringify(state.nations)).toBe(beforeNations);
expect(JSON.stringify(state.regionStats)).toBe(beforeRegions);
});
});
+237 -15
View File
@@ -7,6 +7,7 @@ import type {
EventOutcome,
EventType,
GameState,
MapMarker,
Nation,
NationStats,
Preset,
@@ -18,9 +19,16 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
clampStat,
deriveNationStats,
fnv1a32,
isDateLike,
isMarkerType,
makeEvent,
markerId,
MAX_MARKERS,
nationForPrompt,
type MarkerInfo,
type MarkerSpec,
type NationOp,
type TurnEventSpec,
type TurnResult,
@@ -187,12 +195,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).',
'- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".',
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
@@ -232,6 +242,14 @@ export function buildUserPrompt(
* Omitted entirely when undefined.
*/
noMansLand?: string[],
/**
* (v0.27, #44) The game's current map markers, as the model sees them:
* stable id, type, name, region key and current owner. Emitted verbatim
* (even when empty) so the model can reference a marker's id in
* `TurnResult.markers.remove` or leave an existing marker untouched.
* Omitted entirely when undefined.
*/
markers?: MarkerInfo[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -270,6 +288,7 @@ export function buildUserPrompt(
if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
return JSON.stringify(payload, null, 2);
}
@@ -393,6 +412,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [], remove: [] },
};
if (Array.isArray(o.events)) {
@@ -482,9 +502,51 @@ export function validateTurnResult(input: unknown): TurnResult | null {
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
result.markers = validateMarkerOps(o.markers);
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
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
@@ -498,7 +560,10 @@ export function isMeaningful(result: TurnResult): boolean {
result.events.length > 0 ||
result.stateChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0
result.nationOps.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
);
}
@@ -516,15 +581,38 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
/**
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory
* moves (including no-man's-land releases, v0.22 #16 P3). It also appends any
* proposed nation-ops to the pending list (v0.23 #16 P4a) without applying
* them — see the doc comment on `pendingNationOps` for why that list
* deliberately survives across turns.
* events, apply stat changes (clamped), symmetric relations, territory moves
* (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27
* #44). It also appends any proposed nation-ops to the pending list (v0.23
* #16 P4a) without applying them — see the doc comment on `pendingNationOps`
* for why that list deliberately survives across turns.
*
* `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid
* (`{ lat, lon, spread }`, from public/region-centroids.json via
* server/regions.ts). New markers are positioned at that centroid plus a small
* deterministic offset; a spec whose key has no known centroid is dropped.
* Without it no marker can be placed (all adds are dropped), which keeps the
* engine pure and lets tests inject their own data.
*
* `geoOf` (v0.28, #46) resolves a region key to its area + population
* (from public/region-geo.json via server/regions.ts), used to weight the
* end-of-turn recompute of each nation's derived stats as the
* area×population-weighted median of its regions' stats. Without it the
* recompute degrades to an unweighted median (every region weight 1) — still
* region-consistent, just unweighted. The engine stays pure either way.
*/
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
export function applyTurnResult(
state: GameState,
result: TurnResult,
validKeys?: ReadonlySet<string>,
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
// field starts empty (the turn then re-inits claimed regions as it goes).
next.regionStats = next.regionStats ?? {};
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -533,11 +621,6 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
for (const ev of result.events) {
next.events.push(makeEvent(ev, actingTurn, next.date));
}
for (const c of result.stateChanges) {
const n = next.nations[c.nation];
if (!n) continue;
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
}
for (const r of result.relations) {
const a = next.nations[r.a];
const b = next.nations[r.b];
@@ -587,6 +670,10 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
unowned.add(key);
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
// (v0.28, #46) a released region leaves no-man's land ownerless — its
// per-region stats leave the table with it (no man's land carries none;
// a later re-claim re-inits the entry from the gainer, see below).
delete next.regionStats[key];
}
// (2) Claims against the post-release ownership snapshot.
@@ -633,9 +720,119 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
}
next.nations[gainer].territory.push(key);
unowned.delete(key); // re-claimed: the key leaves no man's land
// (v0.28, #46) a transferred region keeps its own per-region stats (the
// conquest is what makes a nation's regions diverge); only a re-claimed
// region that has no entry left (it was released earlier this turn) is
// re-inited as a copy of the gainer's current stats, so the recompute
// below never sees a hole in the table.
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
}
next.unowned = [...unowned];
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
// the regions, then every nation's stats recomputed as the derived
// area×population-weighted median of its regions ---
//
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
// applying to EVERY region the nation owns: each owned region's copy of
// that stat gets the same `set`/`add` (clamped to 0–100), and the
// nation-level stat is updated too (it is kept as-is for nations the
// recompute below does not touch — see (b) — and overwritten for the
// rest, where the regions are the source of truth).
//
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
// median of its owned regions' stats (via deriveNationStats, weighted
// by area×population when `geoOf` is available, unweighted otherwise).
// Two guards keep degenerate states sane: a nation with NO regions
// keeps its stats untouched (it has no regions to aggregate), and a
// nation none of whose regions has a stats entry keeps its stats
// too (deriveNationStats would return zeros, which would be wrong).
// This recompute is what makes a conquered region's foreign stats
// blend into the conqueror's aggregate and keeps the derived stats in
// lockstep with the per-region table across the turn's changes.
for (const c of result.stateChanges) {
const n = next.nations[c.nation];
if (!n) continue;
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
}
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
}
for (const n of Object.values(next.nations)) {
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
// post-claim ownership, in this order:
// (1) REMOVE: a marker whose id is in result.markers.remove is dropped
// (a listed-but-absent id is a no-op).
// (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to
// its region's current owner — a captured region follows the new
// owner, a region released to no man's land (or a key with 0 or 2+
// owners) becomes `null`. A marker always follows its region.
// (3) ADD: each new spec (in order) is placed at its region's centroid
// (injected via centroidOf) plus a small deterministic per-axis
// offset, owned by the region's post-turn owner, and stamped with
// the acting turn. Dropped (never throws): a region key that is not
// in validKeys or has no known centroid, an id that already exists
// (a re-add is idempotent — the existing marker stays), and any adds
// beyond MAX_MARKERS (the later ones).
{
const finalOwner = new Map<string, string | null>();
{
const owners = new Map<string, string[]>();
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
if (!valid(key)) continue;
const list = owners.get(key);
if (list) list.push(n.id);
else owners.set(key, [n.id]);
}
}
for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null);
}
const removeIds = new Set(result.markers.remove);
const markers: MapMarker[] = [];
for (const m of next.markers ?? []) {
if (removeIds.has(m.id)) continue;
markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null });
}
// Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit
// fraction of the marker id (stable across runs and platforms), so
// markers of one region scatter naturally instead of stacking.
const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => {
const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff;
return (frac * 2 - 1) * (spread / 2);
};
const round6 = (v: number): number => Math.round(v * 1e6) / 1e6;
for (const spec of result.markers.add) {
const id = markerId(spec.type, spec.name, spec.regionKey);
if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing
if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess
if (!valid(spec.regionKey)) continue; // unknown region key
const c = centroidOf?.(spec.regionKey);
if (!c) continue; // no centroid data for this key
markers.push({
id,
type: spec.type,
name: spec.name,
regionKey: spec.regionKey,
lat: round6(c.lat + jitter(id, 'lat', c.spread)),
lon: round6(c.lon + jitter(id, 'lon', c.spread)),
ownerNationId: finalOwner.get(spec.regionKey) ?? null,
placedTurn: actingTurn,
});
}
next.markers = markers;
}
// (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they
// are appended to the pending list, which (unlike `pending`) SURVIVES
// across turns until the player confirms or rejects each one. `next` is a
@@ -965,15 +1162,39 @@ export async function runTurn(opts: {
* switches the turn onto the getNeighbours tool path.
*/
getNeighbours?: (keys: string[]) => string[];
/**
* (v0.27, #44) Region-key -> precomputed centroid resolver (from
* public/region-centroids.json). Present, the turn's new markers can be
* placed at their region's centroid; absent, marker adds are dropped and
* the existing markers' owners still get recomputed.
*/
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
/**
* (v0.28, #46) Region-key -> area/population resolver (from
* public/region-geo.json). Present, the end-of-turn recompute of each
* nation's derived stats weights each owned region by area×population;
* absent, the recompute degrades to an unweighted (uniform) median —
* still region-consistent, just unweighted.
*/
geoOf?: (key: string) => { area: number; population: number } | undefined;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
// list on a fresh game) so it knows which unowned regions can be re-claimed.
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
// (v0.27, #44) likewise the current map markers, so it can reference their
// stable ids in `markers.remove` (or leave them untouched).
const markers = (state.markers ?? []).map((m) => ({
id: m.id,
type: m.type,
name: m.name,
regionKey: m.regionKey,
owner: m.ownerNationId,
}));
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -1018,10 +1239,11 @@ export async function runTurn(opts: {
territoryChanges: [],
unowned: [],
nationOps: [],
markers: { add: [], remove: [] },
};
}
return { state: applyTurnResult(state, result, validRegionKeys), result };
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------
+21 -2
View File
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import { deepClone, seedRegionStats } from '../shared/protocol';
import type {
AddActionRequest,
AddDiplomacyRequest,
@@ -22,7 +22,7 @@ import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -134,11 +134,20 @@ app.post('/api/games', (req: Request, res) => {
// list grows as the LLM proposes renames (and, later, splits/merges) and
// shrinks as the player confirms or rejects them.
pendingNationOps: [],
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
markers: [],
// (v0.28, #46) Per-region stats are seeded below, once territories hold
// real region keys — each region starts as a copy of its owner's initial
// stats, so the derived aggregate equals the nation's stats on turn 1.
regionStats: {},
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
// granularity from the very first turn. Preset JSONs stay country-level.
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
// region key gets its owner's initial stats (see seedRegionStats).
state.regionStats = seedRegionStats(state.nations);
db.save(state);
res.json({ game: state });
});
@@ -288,6 +297,16 @@ app.post('/api/games/:id/jump', async (req, res) => {
// engine the resolver — the turn then runs with the LLM's getNeighbours
// tool and the player-frontier prompt field (both switch off without it).
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
// resolver so a turn can place new map markers at their region's
// centroid (without it, marker adds are dropped; existing markers are
// still maintained).
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
// hand the engine the resolver so the end-of-turn recompute weights each
// owned region by area×population (without it the aggregate degrades to
// an unweighted median — still region-consistent, just unweighted).
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
});
db.save(state);
broadcast(id, 'turn', { state, result });
+3
View File
@@ -49,6 +49,9 @@ const state: GameState = {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
regionStats: {},
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest';
import {
deriveNationStats,
migrateRegionStats,
seedRegionStats,
weightedMedian,
} from '../shared/protocol';
import type { GameState, Nation, NationStats } from '../shared/types';
// --- Test fixtures -----------------------------------------------------------
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
military,
economy,
stability,
population,
});
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
function mkState(
nations: Record<string, Nation>,
regionStats?: Record<string, unknown>,
): GameState {
const base: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: Object.keys(nations)[0] ?? '',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats: {} as GameState['regionStats'],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
return base;
}
// --- weightedMedian ----------------------------------------------------------
describe('weightedMedian', () => {
it('returns 0 for an empty input or zero total weight', () => {
expect(weightedMedian([], [])).toBe(0);
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
});
it('returns the single value when it is the only weighted input', () => {
expect(weightedMedian([42], [7])).toBe(42);
});
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
// The invariant seeding + legacy migration rely on: equal values → that value,
// regardless of the (arbitrary) weights.
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
// and a constant shift shifts the result by the same constant
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
});
it('picks the value at the first point the cumulative weight reaches half the total', () => {
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
// an odd middle: the true middle value
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
// the heavier value dominates
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
});
it('treats non-finite or non-positive weights as excluded (0)', () => {
// NaN weight on 10, positive on 20 → only 20 counts
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
// negative weight on 20, positive on 10 → only 10 counts
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
});
});
// --- deriveNationStats -------------------------------------------------------
describe('deriveNationStats', () => {
const regionStats = {
a: S(50, 50, 50, 50),
b: S(90, 90, 90, 90),
};
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
it('weights by area×population: the larger region dominates the aggregate', () => {
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
// equal weights 1,1 → the lower (a=50) is reached at half-total first
const out = deriveNationStats(['a', 'b'], regionStats);
expect(out).toEqual(S(50, 50, 50, 50));
// same when geoOf is provided but returns undefined for the keys (missing geo)
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
expect(out2).toEqual(S(50, 50, 50, 50));
});
it('skips a region key that has no stats entry', () => {
// b has no entry → only a is counted → a's (50) values
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
expect(out).toEqual(S(50, 50, 50, 50));
});
it('returns zero stats when none of the owned regions has a stats entry', () => {
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
expect(out).toEqual(S(0, 0, 0, 0));
});
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
});
it('treats zero/negative area or population as a fallback weight of 1', () => {
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
});
// --- seedRegionStats ---------------------------------------------------------
describe('seedRegionStats', () => {
it('copies each owner\'s stats to every region it owns', () => {
const nations = {
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
};
expect(seedRegionStats(nations)).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(95, 70, 75, 90),
});
});
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
const nations = {
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
};
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
});
it('returns an empty table for nations with no territory', () => {
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
expect(seedRegionStats(nations)).toEqual({});
});
});
// --- migrateRegionStats ------------------------------------------------------
describe('migrateRegionStats', () => {
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
const state = mkState(
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
);
const out = migrateRegionStats(state);
expect(out).toBe(state); // same reference: untouched
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
});
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
});
// sanity: we built a legacy row
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
const out = migrateRegionStats(state, (k) =>
k === 'k1' ? { area: 100, population: 1000 }
: k === 'k2' ? { area: 1, population: 1 }
: k === 'k3' ? { area: 9, population: 9 }
: { area: 50, population: 500 });
// each owned region is seeded with a copy of its owner's stats…
expect(out.regionStats).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(80, 70, 60, 50),
k4: S(95, 70, 75, 90),
});
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
// identical values is that value, regardless of the (uneven) weights.
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
// the original row is NOT mutated
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
});
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
});
const out = migrateRegionStats(state);
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
expect('uk' in (out.regionStats ?? {})).toBe(false);
});
});
+72
View File
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import {
allTerritoryKeys,
centroidOf,
expandCountriesToKeys,
geoOf,
hasAdjacency,
hasCentroids,
hasGeo,
hasRegionData,
loadRegions,
neighboursOf,
@@ -103,6 +107,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
regionStats: {},
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -133,6 +140,34 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(neighboursOf([])).toEqual([]);
});
it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => {
expect(hasCentroids()).toBe(true);
const ain = centroidOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.lat).toBeCloseTo(46.16, 1);
expect(ain!.lon).toBeCloseTo(4.8, 1);
expect(ain!.spread).toBeGreaterThan(0);
});
it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => {
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
});
it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => {
expect(hasGeo()).toBe(true);
const ain = geoOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.area).toBeGreaterThanOrEqual(0); // km²
expect(ain!.population).toBeGreaterThanOrEqual(0);
// exact spot-check values pinned from the committed manifest (Ain, FRA)
expect(ain!.area).toBeCloseTo(22557.77, 1); // km²
expect(ain!.population).toBe(2304126);
});
it('(v0.28 #46) geoOf returns undefined for an unknown key', () => {
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
});
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
@@ -146,6 +181,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// and no adjacency: the LLM tool / frontier field switch off
expect(hasAdjacency()).toBe(false);
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
// and no centroids: the marker-placement path degrades to a no-op
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
// and no region-geo: the per-region stats weighting degrades to uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
});
});
@@ -197,6 +238,37 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
expect(loadRegions(prodRoot)).toBe(true);
expect(hasAdjacency()).toBe(false);
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
// and no centroids (not copied into this dist/) -> marker placement degrades
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
});
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
copyFileSync(
path.join(realRoot, 'public', 'region-centroids.json'),
path.join(prodRoot, 'dist', 'region-centroids.json'),
);
expect(loadRegions(prodRoot)).toBe(true);
expect(hasCentroids()).toBe(true);
const ain = centroidOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.lat).toBeCloseTo(46.16, 1);
});
it('(v0.28 #46) a dist/ WITH the region-geo manifest loads it (prod layout)', () => {
copyFileSync(
path.join(realRoot, 'public', 'region-geo.json'),
path.join(prodRoot, 'dist', 'region-geo.json'),
);
expect(loadRegions(prodRoot)).toBe(true);
expect(hasGeo()).toBe(true);
const ain = geoOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.area).toBeCloseTo(22557.77, 1);
expect(ain!.population).toBe(2304126);
});
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
+127 -6
View File
@@ -2,7 +2,9 @@
// (region-keys.json: stable feature id -> region key), the sub-national geo
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
// bordering region keys, built by scripts/build-region-adjacency.mjs) — all
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
// precomputed region-centroid manifest (region-centroids.json: region key ->
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
// joins them into the lookups the region-level ownership model needs:
@@ -13,6 +15,10 @@
// - neighboursOf(): the union of the bordering keys for a set of keys — the
// data behind the LLM's getNeighbours tool and the player-frontier prompt
// field (v0.21, #16 P2)
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
// — the data behind LLM-placed map markers (v0.27, #44)
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
// — the weighting source for the per-region stats model (v0.28, #46)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
@@ -39,12 +45,17 @@ interface RegionData {
validKeys: Set<string>;
/** region key -> bordering region keys (from public/region-adjacency.json). */
adjacency: Record<string, string[]>;
/** region key -> anchor point (from public/region-centroids.json). */
centroids: Record<string, { lat: number; lon: number; spread: number }>;
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
geo: Record<string, { area: number; population: number }>;
}
let data: RegionData | null = null;
/**
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
* Idempotent.
* The files are served to the client from `public/` in dev (Vite serves it
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
@@ -52,7 +63,10 @@ let data: RegionData | null = null;
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
* report "no adjacency" (the LLM tool and the frontier prompt field switch
* off; everything else keeps working). Returns true when the core pair loaded
* off; everything else keeps working). The centroid manifest is likewise
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
* placed, but everything else keeps working). Returns true when the core pair loaded
* cleanly; a missing/broken pair clears the data and returns false so the
* server still boots and degrades gracefully (expansion becomes a no-op,
* ownership falls back to the raw input keys).
@@ -61,6 +75,8 @@ export function loadRegions(root: string): boolean {
const keyFile = 'region-keys.json';
const geoFile = 'regions.geo.json';
const adjacencyFile = 'region-adjacency.json';
const centroidsFile = 'region-centroids.json';
const regionGeoFile = 'region-geo.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
@@ -74,13 +90,13 @@ export function loadRegions(root: string): boolean {
const geoPath = path.join(base, geoFile);
try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
const geoJson = JSON.parse(readFileSync(geoPath, 'utf8')) as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
const keyToInfo: Record<string, RegionInfo> = {};
const keysByCountry: Record<string, string[]> = {};
const validKeys = new Set<string>();
for (const f of geo.features ?? []) {
for (const f of geoJson.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
@@ -105,7 +121,71 @@ export function loadRegions(root: string): boolean {
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
// Centroids are optional too (v0.27, #44): a missing/broken file means
// "no centroid data" — new markers can't be placed (their adds are
// dropped), but everything else keeps working.
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
const centroidsPath = path.join(base, centroidsFile);
if (existsSync(centroidsPath)) {
try {
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
string,
{ lat?: unknown; lon?: unknown; spread?: unknown }
>;
// Keep only well-formed entries; a corrupt file degrades to "none".
for (const [k, v] of Object.entries(raw)) {
if (
v &&
typeof v.lat === 'number' &&
typeof v.lon === 'number' &&
typeof v.spread === 'number' &&
Number.isFinite(v.lat) &&
Number.isFinite(v.lon) &&
Number.isFinite(v.spread)
) {
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
}
}
} catch (err) {
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
centroids = {};
}
}
// The region-geo manifest (v0.28, #46): area (km²) + population per region
// key — the weighting source for the per-region stats model. OPTIONAL like
// the others: a missing/broken file means "no geo data", and weighting
// degrades to uniform (every region weight 1) while everything else keeps
// working.
let geo: Record<string, { area: number; population: number }> = {};
const geoDataPath = path.join(base, regionGeoFile);
if (existsSync(geoDataPath)) {
try {
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
string,
{ area?: unknown; population?: unknown }
>;
// Keep only well-formed entries; a corrupt file degrades to "none".
for (const [k, v] of Object.entries(raw)) {
if (
v &&
typeof v.area === 'number' &&
Number.isFinite(v.area) &&
v.area >= 0 &&
typeof v.population === 'number' &&
Number.isFinite(v.population) &&
v.population >= 0
) {
geo[k] = { area: v.area, population: v.population };
}
}
} catch (err) {
console.error('[regions] failed to load region-geo.json, continuing without it', err);
geo = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
@@ -189,6 +269,47 @@ export function hasAdjacency(): boolean {
return data !== null && Object.keys(data.adjacency).length > 0;
}
/**
* True when the region-centroid manifest loaded with at least one entry
* (v0.27, #44). The server's jump route uses this to decide whether to hand
* the engine a centroid resolver (which lets a turn place new map markers)
* or skip marker placement (existing markers are still maintained).
*/
export function hasCentroids(): boolean {
return data !== null && Object.keys(data.centroids).length > 0;
}
/**
* True when the region-geo manifest loaded with at least one entry (v0.28,
* #46). The server's jump route uses this to decide whether to hand the
* engine a geo resolver (which weights the per-region stats derivation by
* area×population) or fall back to a uniform (unweighted) median.
*/
export function hasGeo(): boolean {
return data !== null && Object.keys(data.geo).length > 0;
}
/**
* The precomputed anchor point for a region key: the centroid of its
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
* dimension, in degrees) the engine scatters markers within
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
* centroid data is loaded (v0.27, #44).
*/
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
return data?.centroids[key];
}
/**
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
* weighting source for the per-region stats model. `undefined` for an unknown
* key or when no geo data is loaded (the derivation then falls back to a
* uniform weight of 1 for that region).
*/
export function geoOf(key: string): { area: number; population: number } | undefined {
return data?.geo[key];
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for
+12
View File
@@ -31,6 +31,13 @@ export interface StubPayload {
* is covered by unit tests against applyTurnResult, not by the stub.
*/
unowned?: string[];
/**
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never places or removes a marker (see deterministicTurn), so the marker
* path is covered by unit tests against applyTurnResult, not by the stub.
*/
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
}
function truncate(s: string, n: number): string {
@@ -61,6 +68,10 @@ function truncate(s: string, n: number): string {
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
* always returns `nationOps: []`) — the propose→confirm/reject flow is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
* always returns `markers: { add: [], remove: [] }`) — the marker path is
* covered by unit tests only, keeping the e2e assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -126,6 +137,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
}
+242
View File
@@ -6,10 +6,14 @@ import type {
EventOutcome,
EventType,
GameState,
MarkerType,
Nation,
NationStats,
RelationStatus,
RegionStats,
StatKey,
} from './types';
import { MARKER_TYPES, STAT_KEYS } from './types';
/** A numeric change to one of a nation's 0–100 stats. */
export type StateChange =
@@ -114,6 +118,100 @@ export interface TurnEventSpec {
outcome: EventOutcome;
}
// --- Map markers (v0.27, #44) -------------------------------------------------
//
// The LLM places named point markers (cities, factories, armies, ports,
// capitals, fleets, rocket launch sites, gatherings) anchored to a region
// key. It never sees or sets coordinates: the engine derives the position
// from the region's precomputed centroid (public/region-centroids.json) plus a
// small deterministic offset, and it recomputes each marker's owner from the
// region's current owner on every turn (a released region → null).
//
// The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit →
// base 26, the same scheme as the region keys' 4-char suffix) derived from
// (type, name, regionKey) — so re-adding an identical marker is idempotent
// (same id, skipped) and removing by id is unambiguous.
/** The spec the LLM emits to place a marker: type, name and the region it sits in. */
export interface MarkerSpec {
type: MarkerType;
name: string;
regionKey: string;
}
/**
* One existing marker as the user prompt presents it to the model (v0.27,
* #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`),
* type, name, region key, and the id of the nation currently owning its
* region (`null` when the region is unowned / no man's land).
*/
export interface MarkerInfo {
id: string;
type: MarkerType;
name: string;
regionKey: string;
owner: string | null;
}
/**
* The per-type presentation of a marker: its display label (tooltip /
* dossier), its color, and its glyph character (a symbol-layer text label —
* a small, font-safe character in front of the name). Pure + shared so the
* client's marker layer and any server-side consumers agree on the look.
*/
export const MARKER_TYPE_INFO: Record<MarkerType, { label: string; color: string; glyph: string }> = {
city: { label: 'City', color: '#cbd5e1', glyph: '●' },
factory: { label: 'Factory', color: '#fb923c', glyph: '■' },
army: { label: 'Army', color: '#ef4444', glyph: '▲' },
port: { label: 'Port', color: '#38bdf8', glyph: '◆' },
capital: { label: 'Capital', color: '#fbbf24', glyph: '★' },
fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' },
rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' },
gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' },
};
/** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */
export const MAX_MARKERS = 400;
/**
* FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use
* (see scripts/build-region-keys.mjs). Exported so the engine can derive
* deterministic per-axis jitter fractions from it (marker placement, v0.27 #44).
*/
export function fnv1a32(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
function code4(h: number): string {
let out = '';
for (let i = 0; i < 4; i++) {
out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26));
}
return out;
}
/**
* The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash
* of `(type, name, regionKey)` (NUL-separated so no field boundary is
* ambiguous). Deterministic across runs and platforms (pure string math), so
* the same (type, name, regionKey) always maps to the same id — re-adding is
* idempotent, removing by id is unambiguous.
*/
export function markerId(type: MarkerType, name: string, regionKey: string): string {
return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`));
}
/** True when `t` is one of the eight known marker types (v0.27, #44). */
export function isMarkerType(t: unknown): t is MarkerType {
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -141,6 +239,16 @@ export interface TurnResult {
* survivor. Empty on turns where the model proposes none.
*/
nationOps: NationOp[];
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
* deterministic offset, sets the owner to the region's post-turn owner,
* derives the stable id, and drops unknown types/region keys, duplicates,
* and adds beyond MAX_MARKERS); `remove` drops markers by their stable id
* (a no-op for an id that is not present). Both required, `[]` on turns
* with no marker activity.
*/
markers: { add: MarkerSpec[]; remove: string[] };
}
/** Per-region map payload: region key -> owning nation + its stable color. */
@@ -409,3 +517,137 @@ export function nationForPrompt(n: Nation) {
relations: n.relations,
};
}
// --- Per-region stats (v0.28, #46) ------------------------------------------
//
// A nation's 0–100 `NationStats` are no longer stored as independent per-nation
// values: they are DERIVED as the area×population-weighted median of the stats
// of the regions the nation owns (`GameState.regionStats`, keyed by region key).
// These pure helpers implement that derivation, the fresh-game / legacy seeding,
// and the one-time migration of a pre-v0.28 row. They have no runtime deps.
/**
* The weighted median of `values` with per-value `weights` (paired by index).
* The pairs are sorted by value ascending and the result is the value at the
* first point where the cumulative weight reaches at least half the total
* weight. An empty input, or a total weight of zero, yields 0. Non-finite or
* non-positive weights are treated as 0 (excluded from the median).
* Translation-invariant: adding a constant to every value shifts the result by
* that constant — so the median of a set of **identical** values is that value
* (the invariant the equal-value seeding and legacy migration rely on).
* Pure + shared.
*/
export function weightedMedian(values: number[], weights: number[]): number {
if (values.length === 0) return 0;
const pairs = values
.map((v, i) => {
const w =
typeof weights[i] === 'number' && Number.isFinite(weights[i]) && weights[i] > 0
? weights[i]
: 0;
return { v: Number.isFinite(v) ? v : 0, w };
})
.filter((p) => p.w > 0);
if (pairs.length === 0) return 0;
const total = pairs.reduce((s, p) => s + p.w, 0);
if (total <= 0) return 0;
pairs.sort((a, b) => a.v - b.v);
let cum = 0;
for (const p of pairs) {
cum += p.w;
if (cum >= total / 2) return p.v;
}
return pairs[pairs.length - 1]!.v;
}
/**
* Derive a nation's aggregate {@link NationStats} from the stats of the regions
* it owns: each of the four stats is the **area×population-weighted median**
* (see {@link weightedMedian}) over the nation's owned region keys. A region's
* weight is `(area > 0 ? area : 1) × (population > 0 ? population : 1)` — both
* factors fall back to 1 when the region has no geo entry (`geoOf` returns
* `undefined` for it), so every region carries weight ≥ 1 and a missing geo
* degrades gracefully to a uniform (unweighted) median. A region key with no
* `regionStats` entry is skipped. Returns the zero stats when the nation owns
* no region with a stats entry (the caller's 0-region guard keeps the nation's
* directly-maintained `n.stats` in that case). Pure + shared.
*/
export function deriveNationStats(
regionKeys: string[],
regionStats: Record<string, RegionStats>,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): NationStats {
const zero: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
const byStat: Record<StatKey, number[]> = {
military: [],
economy: [],
stability: [],
population: [],
};
const weights: number[] = [];
for (const key of regionKeys) {
const rs = regionStats[key];
if (!rs) continue; // a key with no per-region stats is skipped
const g = geoOf?.(key);
const a = g && g.area > 0 ? g.area : 1;
const p = g && g.population > 0 ? g.population : 1;
weights.push(a * p);
for (const k of STAT_KEYS) byStat[k].push(rs[k]);
}
if (weights.length === 0) return zero;
const out: NationStats = { military: 0, economy: 0, stability: 0, population: 0 };
for (const k of STAT_KEYS) out[k] = clampStat(weightedMedian(byStat[k], weights));
return out;
}
/**
* Seed a per-region stats table from a set of nations (used both at game
* creation and by the legacy migration): every owned region key gets a **copy**
* of its owner's current `NationStats`, so a fresh nation's regions all start
* identical (the weighted median of identical values is that value, so the
* derived `n.stats` equals the seeded one). A region with two or more owners
* (corrupt) is seeded by the first owner encountered; a key owned by nobody gets
* no entry. Pure + shared.
*/
export function seedRegionStats(nations: Record<string, Nation>): Record<string, RegionStats> {
const out: Record<string, RegionStats> = {};
for (const n of Object.values(nations)) {
for (const key of n.territory) {
if (!(key in out)) out[key] = { ...n.stats }; // first owner wins (sole ownership holds normally)
}
}
return out;
}
/**
* (v0.28, #46) One-time migration of a **legacy** (pre-v0.28) `GameState` row
* into the per-region-stats model, returning a new state (the input is never
* mutated):
* - a row that already has `regionStats` (a v0.28+ save) is returned
* **UNCHANGED** — its `Nation.stats` are already the correct derived medians
* and must not be recomputed on load (that would drift them);
* - a legacy row (`regionStats` absent at runtime) seeds every owned region key
* with a copy of its owner's current stats (so each nation's regions start
* identical), then recomputes each nation's stats as the weighted median of
* its regions (a 0-region nation keeps its stats). Without `geoOf` the
* weighting is uniform, which is weight-invariant for the identical-value
* case, so the recomputed stats equal the originals — the migration is a
* no-op on the visible numbers and only materializes the (previously
* implicit) per-region values. Never throws.
*/
export function migrateRegionStats(
state: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): GameState {
// A v0.28+ row already carries per-region stats: leave it exactly as saved.
if (state.regionStats !== undefined) return state;
// Legacy row: materialize per-region stats from each nation's (uniform) stats.
const next: GameState = { ...state, regionStats: seedRegionStats(state.nations) };
for (const n of Object.values(next.nations)) {
if (n.territory.length === 0) continue; // 0-region guard: keep its stats
if (n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
return next;
}
+90
View File
@@ -13,6 +13,19 @@ export interface NationStats {
population: number;
}
/**
* (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats
* are the unit of record: a nation's aggregate {@link NationStats} is derived as
* the area×population-weighted median of the stats of the regions it owns (see
* `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine.
*/
export interface RegionStats {
military: number;
economy: number;
stability: number;
population: number;
}
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
export type StatKey = (typeof STAT_KEYS)[number];
@@ -68,6 +81,60 @@ export const EVENT_TYPES: EventType[] = [
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. */
export interface EventEntry {
id: string;
@@ -153,6 +220,29 @@ export interface GameState {
* runtime dependencies.)
*/
pendingNationOps: NationOp[];
/**
* (v0.27, #44) Named point markers on the map: the LLM places them (via a
* turn's `markers.add`) and removes them (`markers.remove`, by id). The
* engine positions each at its region's centroid (deterministic small
* offset) and recomputes `ownerNationId` from the region's current owner on
* EVERY committed turn (a released region → `null`). Always `[]` on a fresh
* game; rows saved before v0.27 load with this defaulted to `[]` (see
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
*/
markers: MapMarker[];
/**
* (v0.28, #46) Per-region stats, keyed by **region key** (see
* `public/region-keys.json`): each owned region carries its own four 0–100
* stats, and a nation's aggregate `Nation.stats` is the area×population-
* weighted median of the regions it owns (see `protocol.deriveNationStats`).
* A region that ends a turn **unowned** (released to no man's land) has NO
* entry, and a preset-unassigned region (never owned) has none until first
* claimed (re-claiming re-inits it to the gainer's stats). Always present on a
* fresh game (every owned region seeded with its owner's initial stats); a
* legacy SQLite row without the field is migrated at load (see server/db.ts)
* — additive, no data wipe.
*/
regionStats: Record<string, RegionStats>;
}
/**
+1
View File
@@ -706,6 +706,7 @@ function GameScreen({
nations={game.nations}
presetId={game.presetId}
unowned={game.unowned ?? []}
markers={game.markers ?? []}
onFocusNation={focusNation}
/>
+333 -5
View File
@@ -10,12 +10,14 @@ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl);
import {
mapFeatureOwner,
MARKER_TYPE_INFO,
nationColor,
regionFillStyle,
unownedTooltipLabel,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
import type { Nation } from '@shared/types';
import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types';
import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions';
@@ -45,8 +47,16 @@ type GeoSource = maplibregl.GeoJSONSource;
// looked black, and `styledata` was gated on fetching a remote document. An
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
// raster has no vector tiles, so no glyphs endpoint is needed — we don't add
// label layers; the choropleth provides the borders.)
// raster has no vector tiles, so the basemap itself needs no label layers —
// the choropleth provides the borders.)
//
// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is
// raster (its labels are baked into the PNG tiles), and the LLM-placed map
// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol
// layer — fully self-contained, so they render offline and deterministically
// without depending on any external font/glyph endpoint (the OpenFreeMap
// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come
// from the raster, not from glyphs).
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
const INLINE_STYLE: maplibregl.StyleSpecification = {
@@ -88,6 +98,8 @@ const PLAYER_LINE = 'player-line';
const FLASH_LINE = 'flash-line';
const HOVER_FILL = 'hover-fill';
const HOVER_LINE = 'hover-line';
const MARKERS_SOURCE = 'markers';
const MARKER_SYMBOL = 'markers-symbol';
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
@@ -195,6 +207,186 @@ function featuresByRegionKeys(
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
}
// --- Map markers (v0.27, #44) ------------------------------------------------
//
// The LLM places named point markers (cities, factories, armies, ports,
// capitals, fleets, rocket sites, gatherings) anchored to a region key. The
// engine positions each at its region's centroid (deterministic small offset)
// and recomputes its owner from the region's current owner every turn. The
// map renders them as a single top-most symbol layer: a per-type glyph
// character in front of the name, per-type color, dark-theme halo.
/**
* The GeoJSON behind the marker symbol layer: one Point per marker at
* `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the
* current owner's name + stable color (empty when the region is unowned).
* Pure; the caller passes the current marker list + nations record, so a
* renamed/removed owner is picked up on the next paint.
*/
function buildMarkersFC(markers: MapMarker[], nations: Record<string, Nation>): FeatureCollection {
const features = markers.map((m) => {
const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined;
return {
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] },
properties: {
id: m.id,
type: m.type,
name: m.name,
glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '',
owner: owner?.name ?? '',
ownerId: owner?.id ?? '',
ownerColor: owner ? nationColor(owner.id) : '',
},
};
});
return { type: 'FeatureCollection', features };
}
// Small canvas-path helpers for the marker icons (self-contained shapes — no
// unicode/font dependency, so they render identically in any environment).
function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function starPath(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
spikes: number,
outer: number,
inner: number,
): void {
let rot = (Math.PI / 2) * -1;
const step = Math.PI / spikes;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
for (let i = 0; i < spikes; i++) {
rot += step;
ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner);
rot += step;
ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
}
ctx.closePath();
}
/**
* (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct
* shape per type, filled with the type's color from the shared
* `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any
* choropleth fill. Added to the map as a named image and referenced by the
* symbol layer's `icon-image` — a real symbol layer, per-type color + form,
* with zero external font/glyph dependency.
*/
function drawMarkerIcon(type: MapMarker['type']): ImageData {
const S = 44;
const c = document.createElement('canvas');
c.width = S;
c.height = S;
const ctx = c.getContext('2d')!;
const cx = S / 2;
const cy = S / 2;
ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1';
ctx.strokeStyle = '#0b1220';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
switch (type) {
case 'city':
ctx.beginPath();
ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2);
break;
case 'factory': {
const h = S * 0.5;
roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5);
break;
}
case 'army': {
const r = S * 0.34;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy + r * 0.8);
ctx.lineTo(cx - r, cy + r * 0.8);
ctx.closePath();
break;
}
case 'port': {
const r = S * 0.36;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx + r, cy);
ctx.lineTo(cx, cy + r);
ctx.lineTo(cx - r, cy);
ctx.closePath();
break;
}
case 'capital':
starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15);
break;
case 'fleet':
case 'gathering':
ctx.beginPath();
ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2);
break;
case 'rocket': {
const w = S * 0.14;
ctx.beginPath();
ctx.moveTo(cx, cy - S * 0.36);
ctx.lineTo(cx + S * 0.22, cy - S * 0.04);
ctx.lineTo(cx + w, cy - S * 0.04);
ctx.lineTo(cx + w, cy + S * 0.34);
ctx.lineTo(cx - w, cy + S * 0.34);
ctx.lineTo(cx - w, cy - S * 0.04);
ctx.lineTo(cx - S * 0.22, cy - S * 0.04);
ctx.closePath();
break;
}
}
ctx.fill();
ctx.stroke();
// fleet and gathering share a circle base; an inner mark tells them apart
// (fleet = a dark porthole dot, gathering = a white bullseye dot).
if (type === 'fleet' || type === 'gathering') {
ctx.beginPath();
ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2);
ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff';
ctx.fill();
}
// Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe)
// rather than the canvas element — `addImage` accepts ImageData directly.
return ctx.getImageData(0, 0, S, S);
}
/** The hover tooltip for a marker: its type label + name, then the owner chip. */
function markerTooltipHtml(
type: string,
name: string,
owner: string,
ownerColor: string,
): string {
const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker';
const ownerLine = owner
? `<div class="hm-popup-owner"><span class="hm-chip" style="background:${ownerColor}"></span>${esc(owner)}</div>`
: '<div class="hm-popup-sub">No owner</div>';
return `<div class="hm-popup"><div class="hm-popup-title">${esc(label)}: ${esc(name)}</div>${ownerLine}</div>`;
}
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
@@ -254,6 +446,13 @@ interface Props {
* component doc for the full behavior.
*/
onFocusNation?: (id: string, info?: { name?: string }) => void;
/**
* (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM
* places/removes them over time, the engine positions each at its region's
* centroid and keeps its owner in sync with the region's owner. Rendered as
* a top-most symbol layer (per-type glyph + name, per-type color).
*/
markers?: MapMarker[];
}
/**
@@ -286,6 +485,18 @@ interface Props {
* a plain "No owner" sub-line. The colors/labels come from the shared
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
*
* (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`)
* render on a top-most symbol layer: each is its type's canvas-generated icon
* (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO`
* contract, dark-outlined) at the engine-derived position (region centroid +
* small deterministic offset). The icons are drawn into named map images
* (`marker-<type>`), so there is no external font/glyph dependency and markers
* render offline and deterministically. Hovering a marker shows a tooltip
* (type label + name + owner chip); clicking one switches the dossier to the
* owning nation (no-op when the marker's region is unowned). The region
* hover/click handlers detect a marker under the cursor (MapLibre layer events
* have no propagation) and act on the marker's data.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
*/
@@ -297,6 +508,7 @@ export default function WorldMap({
className,
unowned,
onFocusNation,
markers,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -311,12 +523,14 @@ export default function WorldMap({
const presetRef = useRef(presetId);
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
const onFocusNationRef = useRef(onFocusNation);
const markersRef = useRef<MapMarker[]>(markers ?? []);
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
presetRef.current = presetId;
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? [];
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -424,6 +638,39 @@ export default function WorldMap({
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
// (v0.27, #44) A marker rendered on top of this region wins the hover.
// The region polygon is always beneath a marker, so this FILL_LAYER
// delegate fires in both cases; if a marker icon is under the cursor,
// show the marker's tooltip (name + type + owner) instead of the
// region's. `e.point` is the cursor's pixel position, so querying the
// marker layer there tests the icon's (small) hit area, not the whole
// region.
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
if (markerHits.length > 0) {
const mp = (markerHits[0].properties ?? {}) as {
type?: string;
name?: string;
owner?: string;
ownerColor?: string;
};
hoverPopup
.setLngLat(e.lngLat)
.setHTML(
markerTooltipHtml(
typeof mp.type === 'string' ? mp.type : '',
typeof mp.name === 'string' ? mp.name : '',
typeof mp.owner === 'string' ? mp.owner : '',
typeof mp.ownerColor === 'string' ? mp.ownerColor : '',
),
)
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
// A marker has no region outline to highlight; clear the previous one
// (and reset lastHoverId so moving back onto a region re-triggers it).
lastHoverId = null;
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
return;
}
const p = f.properties as { name?: string; regionId?: string };
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
if (subId && subId !== lastHoverId) {
@@ -466,6 +713,19 @@ export default function WorldMap({
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
// (v0.27, #44) A marker under the cursor: focus its owning nation
// (identical to its region's owner — the engine keeps a marker's owner in
// sync with the region) and carry the marker's name into the dossier.
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
if (markerHits.length > 0) {
const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string };
const owner = typeof mp.ownerId === 'string' ? mp.ownerId : '';
if (owner) {
const info = typeof mp.name === 'string' ? { name: mp.name } : undefined;
onFocusNationRef.current?.(owner, info);
}
return;
}
const region = mapFeatureOwner(regionsRef.current, f);
if (!region?.ownerId) return;
// Carry the clicked sub-region's name so the dossier can show which region
@@ -497,6 +757,9 @@ export default function WorldMap({
if (!map.getSource(FLASH_SOURCE)) {
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
}
if (!map.getSource(MARKERS_SOURCE)) {
map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() });
}
};
// Stamp ownership + colors onto the regions source (no-op until both the
@@ -509,6 +772,26 @@ export default function WorldMap({
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
};
// Stamp the current markers onto the marker source (v0.27, #44).
const paintMarkers = () => {
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
if (!src) return;
src.setData(buildMarkersFC(markersRef.current, nationsRef.current));
};
// Register the eight canvas-generated marker-type icon images (v0.27, #44).
// Images are cleared on a style (re)load, so this is (re)run from
// `ensureLayers` (which fires on every `styledata`). Self-contained — no
// external font/glyph endpoint — so markers render offline and
// deterministically.
const ensureMarkerImages = async () => {
for (const type of MARKER_TYPES) {
const id = `marker-${type}`;
if (map.getImage(id)) continue;
await map.addImage(id, drawMarkerIcon(type));
}
};
const ensureLayers = async () => {
if (cancelled) return;
ensureSources();
@@ -528,6 +811,7 @@ export default function WorldMap({
}
if (cancelled) return;
paintRegions();
paintMarkers();
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
@@ -554,8 +838,9 @@ export default function WorldMap({
}
// NOTE: layers are added in bottom→top order (each appends on top). The
// stack is: fill, base borders, player border, territory-change flash,
// hover brighten, hover outline. Do not use a `beforeId` here — it throws
// if that layer does not exist yet on a fresh style load.
// hover brighten, hover outline, and (top-most) the LLM-placed map
// markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it
// throws if that layer does not exist yet on a fresh style load.
if (!map.getLayer(PLAYER_LINE)) {
map.addLayer({
id: PLAYER_LINE,
@@ -589,6 +874,36 @@ export default function WorldMap({
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
});
}
// (v0.27, #44) The LLM-placed map markers: a top-most symbol layer.
// Each marker renders its type's canvas-generated icon (a distinct shape,
// per-type color from the shared MARKER_TYPE_INFO contract) with a dark
// outline for legibility on any choropleth fill. The icon images are
// self-contained (no external font/glyph endpoint), so markers render
// offline and deterministically. `icon-image` is `marker-<type>`, the
// named canvas images registered by `ensureMarkerImages` above.
await ensureMarkerImages();
if (!map.getLayer(MARKER_SYMBOL)) {
map.addLayer({
id: MARKER_SYMBOL,
type: 'symbol',
source: MARKERS_SOURCE,
layout: {
'icon-image': ['concat', 'marker-', ['get', 'type']],
'icon-size': 0.8,
'icon-allow-overlap': true,
'icon-ignore-placement': true,
},
});
}
// Marker interactions: the markers sit above the choropleth, but they
// deliberately share the region (FILL_LAYER) handlers registered below.
// MapLibre layer-scoped events are independent per-layer delegates (no
// propagation, no stopPropagation), so a marker-on-region would fire
// both a marker and a region handler. Instead the region handlers detect
// a marker under the cursor (see onHover/onClick) and act on the
// marker's data; clicking a marker focuses its owning nation via the
// same path as clicking its region (the marker's owner == its region's
// owner).
map.off('mousemove', FILL_LAYER, onHover);
map.off('mouseout', FILL_LAYER, onHoverOut);
map.off('click', FILL_LAYER, onClick);
@@ -646,6 +961,19 @@ export default function WorldMap({
}
}, [regions, playerNationId, unowned]);
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
// changes (a turn placed/removed markers) or the nations record changes
// (a confirmed rename/merge changes an owner's display name). No-op until
// the map + source exist (the mount effect's ensureLayers applies the
// current data once the layer structure is in place).
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
if (!src) return;
src.setData(buildMarkersFC(markers ?? [], nations));
}, [markers, nations]);
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
// `position: relative`, which would otherwise win over a Tailwind class.