v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2) #47
@@ -56,7 +56,7 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 189 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)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
@@ -65,28 +65,35 @@ history/
|
|||||||
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||||
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
||||||
│ ├── region-adjacency.json # region key -> bordering region keys, 1,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-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;
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
│ │ # → build-region-adjacency → build-region-centroids
|
│ │ # → 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
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||||
│ # Jump + NationOp confirm/reject request types + pure helpers
|
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||||
|
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
|
||||||
|
│ # seedRegionStats / migrateRegionStats)
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||||
│ │ # PORT/LLM_*/DATA_DIR
|
│ │ # 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
|
│ ├── 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
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||||
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||||
│ ├── presets.ts # load preset JSON files from disk
|
│ ├── 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)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
└── src/
|
└── src/
|
||||||
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||||||
@@ -116,13 +123,15 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||||
| `npm test` | vitest unit tests (`server/` + `scripts/`; 189 tests) |
|
| `npm test` | vitest unit tests (`server/` + `scripts/`; 225 tests) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `npm run lint` | ESLint flat config |
|
||||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||||
| `npm run 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` | (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 split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||||
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
| `npm run build:region-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` |
|
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||||
|
|
||||||
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
||||||
@@ -286,6 +295,38 @@ history/
|
|||||||
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
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`,
|
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`).
|
`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).**
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
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
|
## 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.
|
- **#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 }`.
|
- **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 }`.
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.27.0",
|
"version": "0.28.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"build:region-keys": "node scripts/build-region-keys.mjs",
|
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||||
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
{
|
||||||
|
"ABW": 106314,
|
||||||
|
"AFG": 38041754,
|
||||||
|
"AGO": 31825295,
|
||||||
|
"AIA": 14731,
|
||||||
|
"ALB": 2854191,
|
||||||
|
"ALD": 29884,
|
||||||
|
"AND": 77142,
|
||||||
|
"ARE": 9770529,
|
||||||
|
"ARG": 44938712,
|
||||||
|
"ARM": 2957731,
|
||||||
|
"ASM": 55312,
|
||||||
|
"ATA": 4490,
|
||||||
|
"ATF": 140,
|
||||||
|
"ATG": 97118,
|
||||||
|
"AUS": 25364307,
|
||||||
|
"AUT": 8877067,
|
||||||
|
"AZE": 10023318,
|
||||||
|
"BDI": 11530580,
|
||||||
|
"BEL": 11484055,
|
||||||
|
"BEN": 11801151,
|
||||||
|
"BFA": 20321378,
|
||||||
|
"BGD": 163046161,
|
||||||
|
"BGR": 6975761,
|
||||||
|
"BHR": 1641172,
|
||||||
|
"BHS": 389482,
|
||||||
|
"BIH": 3301000,
|
||||||
|
"BLM": 9961,
|
||||||
|
"BLR": 9466856,
|
||||||
|
"BLZ": 390353,
|
||||||
|
"BMU": 63918,
|
||||||
|
"BOL": 11513100,
|
||||||
|
"BRA": 211049527,
|
||||||
|
"BRB": 287025,
|
||||||
|
"BRN": 433285,
|
||||||
|
"BTN": 763092,
|
||||||
|
"BWA": 2303697,
|
||||||
|
"CAF": 4745185,
|
||||||
|
"CAN": 37589262,
|
||||||
|
"CHE": 8574832,
|
||||||
|
"CHL": 18952038,
|
||||||
|
"CHN": 1397715000,
|
||||||
|
"CIV": 25716544,
|
||||||
|
"CMR": 25876380,
|
||||||
|
"CNM": 10000,
|
||||||
|
"COD": 86790567,
|
||||||
|
"COG": 5380508,
|
||||||
|
"COK": 17459,
|
||||||
|
"COL": 50339443,
|
||||||
|
"COM": 850886,
|
||||||
|
"CPV": 549935,
|
||||||
|
"CRI": 5047561,
|
||||||
|
"CSI": 4,
|
||||||
|
"CUB": 11333483,
|
||||||
|
"CUW": 157538,
|
||||||
|
"CYM": 64948,
|
||||||
|
"CYN": 326000,
|
||||||
|
"CYP": 1198575,
|
||||||
|
"CZE": 10669709,
|
||||||
|
"DEU": 83132799,
|
||||||
|
"DJI": 973560,
|
||||||
|
"DMA": 71808,
|
||||||
|
"DNK": 5818553,
|
||||||
|
"DOM": 10738958,
|
||||||
|
"DZA": 43053054,
|
||||||
|
"ECU": 17373662,
|
||||||
|
"EGY": 100388073,
|
||||||
|
"ERI": 6081196,
|
||||||
|
"ESB": 7850,
|
||||||
|
"ESP": 47076781,
|
||||||
|
"EST": 1326590,
|
||||||
|
"ETH": 112078730,
|
||||||
|
"FIN": 5520314,
|
||||||
|
"FJI": 889953,
|
||||||
|
"FLK": 3398,
|
||||||
|
"FRA": 67059887,
|
||||||
|
"FRO": 48678,
|
||||||
|
"FSM": 113815,
|
||||||
|
"GAB": 2172579,
|
||||||
|
"GBR": 66834405,
|
||||||
|
"GEO": 3720382,
|
||||||
|
"GGY": 62792,
|
||||||
|
"GHA": 30417856,
|
||||||
|
"GIB": 33701,
|
||||||
|
"GIN": 12771246,
|
||||||
|
"GMB": 2347706,
|
||||||
|
"GNB": 1920922,
|
||||||
|
"GNQ": 1355986,
|
||||||
|
"GRC": 10716322,
|
||||||
|
"GRD": 112003,
|
||||||
|
"GRL": 56225,
|
||||||
|
"GTM": 16604026,
|
||||||
|
"GUM": 167294,
|
||||||
|
"GUY": 782766,
|
||||||
|
"HKG": 7507400,
|
||||||
|
"HND": 9746117,
|
||||||
|
"HRV": 4067500,
|
||||||
|
"HTI": 11263077,
|
||||||
|
"HUN": 9769949,
|
||||||
|
"IDN": 270625568,
|
||||||
|
"IMN": 84584,
|
||||||
|
"IND": 1366417754,
|
||||||
|
"IOA": 2387,
|
||||||
|
"IOT": 3000,
|
||||||
|
"IRL": 4941444,
|
||||||
|
"IRN": 82913906,
|
||||||
|
"IRQ": 39309783,
|
||||||
|
"ISL": 361313,
|
||||||
|
"ISR": 9053300,
|
||||||
|
"ITA": 60297396,
|
||||||
|
"JAM": 2948279,
|
||||||
|
"JEY": 107800,
|
||||||
|
"JOR": 10101694,
|
||||||
|
"JPN": 126264931,
|
||||||
|
"KAB": 36175,
|
||||||
|
"KAS": 6000,
|
||||||
|
"KAZ": 18513930,
|
||||||
|
"KEN": 52573973,
|
||||||
|
"KGZ": 6456900,
|
||||||
|
"KHM": 16486542,
|
||||||
|
"KIR": 117606,
|
||||||
|
"KNA": 52834,
|
||||||
|
"KOR": 51709098,
|
||||||
|
"KOS": 1794248,
|
||||||
|
"KWT": 4207083,
|
||||||
|
"LAO": 7169455,
|
||||||
|
"LBN": 6855713,
|
||||||
|
"LBR": 4937374,
|
||||||
|
"LBY": 6777452,
|
||||||
|
"LCA": 182790,
|
||||||
|
"LIE": 38019,
|
||||||
|
"LKA": 21803000,
|
||||||
|
"LSO": 2125268,
|
||||||
|
"LTU": 2786844,
|
||||||
|
"LUX": 619896,
|
||||||
|
"LVA": 1912789,
|
||||||
|
"MAC": 640445,
|
||||||
|
"MAF": 38002,
|
||||||
|
"MAR": 36471769,
|
||||||
|
"MCO": 38964,
|
||||||
|
"MDA": 2657637,
|
||||||
|
"MDG": 26969307,
|
||||||
|
"MDV": 530953,
|
||||||
|
"MEX": 127575529,
|
||||||
|
"MHL": 58791,
|
||||||
|
"MKD": 2083459,
|
||||||
|
"MLI": 19658031,
|
||||||
|
"MLT": 502653,
|
||||||
|
"MMR": 54045420,
|
||||||
|
"MNE": 622137,
|
||||||
|
"MNG": 3225167,
|
||||||
|
"MNP": 57216,
|
||||||
|
"MOZ": 30366036,
|
||||||
|
"MRT": 4525696,
|
||||||
|
"MSR": 4649,
|
||||||
|
"MUS": 1265711,
|
||||||
|
"MWI": 18628747,
|
||||||
|
"MYS": 31949777,
|
||||||
|
"NAM": 2494530,
|
||||||
|
"NCL": 287800,
|
||||||
|
"NER": 23310715,
|
||||||
|
"NFK": 2169,
|
||||||
|
"NGA": 200963599,
|
||||||
|
"NIC": 6545502,
|
||||||
|
"NIU": 1620,
|
||||||
|
"NLD": 17332850,
|
||||||
|
"NOR": 5347896,
|
||||||
|
"NPL": 28608710,
|
||||||
|
"NRU": 12581,
|
||||||
|
"NZL": 4917000,
|
||||||
|
"OMN": 4974986,
|
||||||
|
"PAK": 216565318,
|
||||||
|
"PAN": 4246439,
|
||||||
|
"PCN": 54,
|
||||||
|
"PER": 32510453,
|
||||||
|
"PGA": 100,
|
||||||
|
"PHL": 108116615,
|
||||||
|
"PLW": 18008,
|
||||||
|
"PNG": 8776109,
|
||||||
|
"POL": 37970874,
|
||||||
|
"PRI": 3193694,
|
||||||
|
"PRK": 25666161,
|
||||||
|
"PRT": 10269417,
|
||||||
|
"PRY": 7044636,
|
||||||
|
"PSX": 4685306,
|
||||||
|
"PYF": 279287,
|
||||||
|
"QAT": 2832067,
|
||||||
|
"ROU": 19356544,
|
||||||
|
"RUS": 144373535,
|
||||||
|
"RWA": 12626950,
|
||||||
|
"SAH": 603253,
|
||||||
|
"SAU": 34268528,
|
||||||
|
"SDN": 42813238,
|
||||||
|
"SDS": 11062113,
|
||||||
|
"SEN": 16296364,
|
||||||
|
"SGP": 5703569,
|
||||||
|
"SGS": 30,
|
||||||
|
"SHN": 4534,
|
||||||
|
"SLB": 669823,
|
||||||
|
"SLE": 7813215,
|
||||||
|
"SLV": 6453553,
|
||||||
|
"SMR": 33860,
|
||||||
|
"SOL": 5096159,
|
||||||
|
"SOM": 10192317,
|
||||||
|
"SPM": 5997,
|
||||||
|
"SRB": 6944975,
|
||||||
|
"STP": 215056,
|
||||||
|
"SUR": 581363,
|
||||||
|
"SVK": 5454073,
|
||||||
|
"SVN": 2087946,
|
||||||
|
"SWE": 10285453,
|
||||||
|
"SWZ": 1148130,
|
||||||
|
"SXM": 40733,
|
||||||
|
"SYC": 97625,
|
||||||
|
"SYR": 17070135,
|
||||||
|
"TCA": 38191,
|
||||||
|
"TCD": 15946876,
|
||||||
|
"TGO": 8082366,
|
||||||
|
"THA": 69625582,
|
||||||
|
"TJK": 9321018,
|
||||||
|
"TKM": 5942089,
|
||||||
|
"TLS": 1293119,
|
||||||
|
"TON": 104494,
|
||||||
|
"TTO": 1394973,
|
||||||
|
"TUN": 11694719,
|
||||||
|
"TUR": 83429615,
|
||||||
|
"TUV": 11646,
|
||||||
|
"TWN": 23568378,
|
||||||
|
"TZA": 58005463,
|
||||||
|
"UGA": 44269594,
|
||||||
|
"UKR": 44385155,
|
||||||
|
"UMI": 300,
|
||||||
|
"URY": 3461734,
|
||||||
|
"USA": 328239523,
|
||||||
|
"USG": 6000,
|
||||||
|
"UZB": 33580650,
|
||||||
|
"VAT": 825,
|
||||||
|
"VCT": 110589,
|
||||||
|
"VEN": 28515829,
|
||||||
|
"VGB": 30030,
|
||||||
|
"VIR": 106631,
|
||||||
|
"VNM": 96462106,
|
||||||
|
"VUT": 299882,
|
||||||
|
"WLF": 11558,
|
||||||
|
"WSB": 7850,
|
||||||
|
"WSM": 197097,
|
||||||
|
"YEM": 29161922,
|
||||||
|
"ZAF": 58558270,
|
||||||
|
"ZMB": 17861030,
|
||||||
|
"ZWE": 14645468
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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')}`,
|
||||||
|
);
|
||||||
@@ -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)`,
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,11 @@ function makeState(id: string): GameState {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
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 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +114,39 @@ describe('openDb', () => {
|
|||||||
db.close();
|
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', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
+9
-1
@@ -2,6 +2,7 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import { migrateRegionStats } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
@@ -39,7 +40,7 @@ export function openDb(filePath: string): Db {
|
|||||||
const row = byId.get(id) as { data: string } | undefined;
|
const row = byId.get(id) as { data: string } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
try {
|
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
|
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||||
if (state.unowned === undefined) state.unowned = [];
|
if (state.unowned === undefined) state.unowned = [];
|
||||||
@@ -48,6 +49,13 @@ export function openDb(filePath: string): Db {
|
|||||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||||
if (state.markers === undefined) state.markers = [];
|
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;
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+219
-7
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, MarkerType, Preset, RoundItem } from '../shared/types';
|
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
|
||||||
import { makeEvent, markerId, MAX_MARKERS, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
|
import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
|
||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,11 @@ const preset: Preset = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function baseState(): GameState {
|
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 {
|
return {
|
||||||
id: 'g1',
|
id: 'g1',
|
||||||
presetId: 'test',
|
presetId: 'test',
|
||||||
@@ -78,17 +83,16 @@ function baseState(): GameState {
|
|||||||
playerNationId: 'uk',
|
playerNationId: 'uk',
|
||||||
turn: 1,
|
turn: 1,
|
||||||
date: '1939-09-01',
|
date: '1939-09-01',
|
||||||
nations: {
|
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 } },
|
|
||||||
},
|
|
||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1908,3 +1912,211 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+69
-7
@@ -19,6 +19,7 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
|||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
clampStat,
|
clampStat,
|
||||||
|
deriveNationStats,
|
||||||
fnv1a32,
|
fnv1a32,
|
||||||
isDateLike,
|
isDateLike,
|
||||||
isMarkerType,
|
isMarkerType,
|
||||||
@@ -592,15 +593,26 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* deterministic offset; a spec whose key has no known centroid is dropped.
|
* 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
|
* Without it no marker can be placed (all adds are dropped), which keeps the
|
||||||
* engine pure and lets tests inject their own data.
|
* 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(
|
export function applyTurnResult(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
result: TurnResult,
|
result: TurnResult,
|
||||||
validKeys?: ReadonlySet<string>,
|
validKeys?: ReadonlySet<string>,
|
||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||||
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
): GameState {
|
): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
|
// (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
|
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.turn = state.turn + 1; // then we advance to the next turn to play
|
||||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||||
@@ -609,11 +621,6 @@ export function applyTurnResult(
|
|||||||
for (const ev of result.events) {
|
for (const ev of result.events) {
|
||||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
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) {
|
for (const r of result.relations) {
|
||||||
const a = next.nations[r.a];
|
const a = next.nations[r.a];
|
||||||
const b = next.nations[r.b];
|
const b = next.nations[r.b];
|
||||||
@@ -663,6 +670,10 @@ export function applyTurnResult(
|
|||||||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||||||
unowned.add(key);
|
unowned.add(key);
|
||||||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== 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.
|
// (2) Claims against the post-release ownership snapshot.
|
||||||
@@ -709,10 +720,53 @@ export function applyTurnResult(
|
|||||||
}
|
}
|
||||||
next.nations[gainer].territory.push(key);
|
next.nations[gainer].territory.push(key);
|
||||||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
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];
|
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) ---
|
// --- Map markers (v0.27, #44) ---
|
||||||
//
|
//
|
||||||
// Markers are anchored to a region key and are processed against the FINAL
|
// Markers are anchored to a region key and are processed against the FINAL
|
||||||
@@ -1115,8 +1169,16 @@ export async function runTurn(opts: {
|
|||||||
* the existing markers' owners still get recomputed.
|
* the existing markers' owners still get recomputed.
|
||||||
*/
|
*/
|
||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
|
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 }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf } = opts;
|
||||||
const chatWithTools = llm.chatWithTools;
|
const chatWithTools = llm.chatWithTools;
|
||||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||||
const system = buildSystemPrompt(preset, useTools);
|
const system = buildSystemPrompt(preset, useTools);
|
||||||
@@ -1181,7 +1243,7 @@ export async function runTurn(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result };
|
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
+14
-2
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { deepClone } from '../shared/protocol';
|
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
@@ -22,7 +22,7 @@ import { createLlmClient } from './llm';
|
|||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, hasAdjacency, hasCentroids, 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 { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
@@ -136,11 +136,18 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||||
markers: [],
|
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
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
// granularity from the very first turn. Preset JSONs stay country-level.
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
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);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -295,6 +302,11 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
// centroid (without it, marker adds are dropped; existing markers are
|
// centroid (without it, marker adds are dropped; existing markers are
|
||||||
// still maintained).
|
// still maintained).
|
||||||
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
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);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const state: GameState = {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
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.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
allTerritoryKeys,
|
allTerritoryKeys,
|
||||||
centroidOf,
|
centroidOf,
|
||||||
expandCountriesToKeys,
|
expandCountriesToKeys,
|
||||||
|
geoOf,
|
||||||
hasAdjacency,
|
hasAdjacency,
|
||||||
hasCentroids,
|
hasCentroids,
|
||||||
|
hasGeo,
|
||||||
hasRegionData,
|
hasRegionData,
|
||||||
loadRegions,
|
loadRegions,
|
||||||
neighboursOf,
|
neighboursOf,
|
||||||
@@ -106,6 +108,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
unowned: [],
|
unowned: [],
|
||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
markers: [],
|
||||||
|
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||||
|
regionStats: {},
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
@@ -149,6 +153,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
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', () => {
|
describe('graceful degradation with no region data', () => {
|
||||||
beforeEach(() => resetRegions());
|
beforeEach(() => resetRegions());
|
||||||
|
|
||||||
@@ -165,6 +184,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
// and no centroids: the marker-placement path degrades to a no-op
|
// and no centroids: the marker-placement path degrades to a no-op
|
||||||
expect(hasCentroids()).toBe(false);
|
expect(hasCentroids()).toBe(false);
|
||||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +241,9 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
// and no centroids (not copied into this dist/) -> marker placement degrades
|
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||||
expect(hasCentroids()).toBe(false);
|
expect(hasCentroids()).toBe(false);
|
||||||
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
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)', () => {
|
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||||
@@ -233,6 +258,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
|
|||||||
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
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', () => {
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
const emptyRoot = path.join(base, 'empty');
|
const emptyRoot = path.join(base, 'empty');
|
||||||
mkdirSync(emptyRoot, { recursive: true });
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
|||||||
+61
-3
@@ -17,6 +17,8 @@
|
|||||||
// field (v0.21, #16 P2)
|
// field (v0.21, #16 P2)
|
||||||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||||
// — the data behind LLM-placed map markers (v0.27, #44)
|
// — 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
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
@@ -45,6 +47,8 @@ interface RegionData {
|
|||||||
adjacency: Record<string, string[]>;
|
adjacency: Record<string, string[]>;
|
||||||
/** region key -> anchor point (from public/region-centroids.json). */
|
/** region key -> anchor point (from public/region-centroids.json). */
|
||||||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
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;
|
let data: RegionData | null = null;
|
||||||
@@ -72,6 +76,7 @@ export function loadRegions(root: string): boolean {
|
|||||||
const geoFile = 'regions.geo.json';
|
const geoFile = 'regions.geo.json';
|
||||||
const adjacencyFile = 'region-adjacency.json';
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
const centroidsFile = 'region-centroids.json';
|
const centroidsFile = 'region-centroids.json';
|
||||||
|
const regionGeoFile = 'region-geo.json';
|
||||||
const base = [
|
const base = [
|
||||||
path.join(root, 'public'), // dev: Vite serves public/ at /
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
@@ -85,13 +90,13 @@ export function loadRegions(root: string): boolean {
|
|||||||
const geoPath = path.join(base, geoFile);
|
const geoPath = path.join(base, geoFile);
|
||||||
try {
|
try {
|
||||||
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
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 } }>;
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
};
|
};
|
||||||
const keyToInfo: Record<string, RegionInfo> = {};
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
const keysByCountry: Record<string, string[]> = {};
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
const validKeys = new Set<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 id = f.id == null ? '' : String(f.id);
|
||||||
const key = idToKey[id];
|
const key = idToKey[id];
|
||||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
@@ -147,7 +152,40 @@ export function loadRegions(root: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, 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;
|
return validKeys.size > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[regions] failed to load region data', err);
|
console.error('[regions] failed to load region data', err);
|
||||||
@@ -241,6 +279,16 @@ export function hasCentroids(): boolean {
|
|||||||
return data !== null && Object.keys(data.centroids).length > 0;
|
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
|
* 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
|
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||||||
@@ -252,6 +300,16 @@ export function centroidOf(key: string): { lat: number; lon: number; spread: num
|
|||||||
return data?.centroids[key];
|
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
|
* The union of the region keys that share a land border with any of the given
|
||||||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||||
|
|||||||
+137
-1
@@ -8,10 +8,12 @@ import type {
|
|||||||
GameState,
|
GameState,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
|
NationStats,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RegionStats,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { MARKER_TYPES } from './types';
|
import { MARKER_TYPES, STAT_KEYS } from './types';
|
||||||
|
|
||||||
/** A numeric change to one of a nation's 0–100 stats. */
|
/** A numeric change to one of a nation's 0–100 stats. */
|
||||||
export type StateChange =
|
export type StateChange =
|
||||||
@@ -515,3 +517,137 @@ export function nationForPrompt(n: Nation) {
|
|||||||
relations: n.relations,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ export interface NationStats {
|
|||||||
population: number;
|
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 const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||||
export type StatKey = (typeof STAT_KEYS)[number];
|
export type StatKey = (typeof STAT_KEYS)[number];
|
||||||
|
|
||||||
@@ -217,6 +230,19 @@ export interface GameState {
|
|||||||
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||||
*/
|
*/
|
||||||
markers: MapMarker[];
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user