Compare commits

..
2 Commits
19 changed files with 8743 additions and 29 deletions
+48 -7
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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"
+251
View File
@@ -0,0 +1,251 @@
{
"ABW": 106314,
"AFG": 38041754,
"AGO": 31825295,
"AIA": 14731,
"ALB": 2854191,
"ALD": 29884,
"AND": 77142,
"ARE": 9770529,
"ARG": 44938712,
"ARM": 2957731,
"ASM": 55312,
"ATA": 4490,
"ATF": 140,
"ATG": 97118,
"AUS": 25364307,
"AUT": 8877067,
"AZE": 10023318,
"BDI": 11530580,
"BEL": 11484055,
"BEN": 11801151,
"BFA": 20321378,
"BGD": 163046161,
"BGR": 6975761,
"BHR": 1641172,
"BHS": 389482,
"BIH": 3301000,
"BLM": 9961,
"BLR": 9466856,
"BLZ": 390353,
"BMU": 63918,
"BOL": 11513100,
"BRA": 211049527,
"BRB": 287025,
"BRN": 433285,
"BTN": 763092,
"BWA": 2303697,
"CAF": 4745185,
"CAN": 37589262,
"CHE": 8574832,
"CHL": 18952038,
"CHN": 1397715000,
"CIV": 25716544,
"CMR": 25876380,
"CNM": 10000,
"COD": 86790567,
"COG": 5380508,
"COK": 17459,
"COL": 50339443,
"COM": 850886,
"CPV": 549935,
"CRI": 5047561,
"CSI": 4,
"CUB": 11333483,
"CUW": 157538,
"CYM": 64948,
"CYN": 326000,
"CYP": 1198575,
"CZE": 10669709,
"DEU": 83132799,
"DJI": 973560,
"DMA": 71808,
"DNK": 5818553,
"DOM": 10738958,
"DZA": 43053054,
"ECU": 17373662,
"EGY": 100388073,
"ERI": 6081196,
"ESB": 7850,
"ESP": 47076781,
"EST": 1326590,
"ETH": 112078730,
"FIN": 5520314,
"FJI": 889953,
"FLK": 3398,
"FRA": 67059887,
"FRO": 48678,
"FSM": 113815,
"GAB": 2172579,
"GBR": 66834405,
"GEO": 3720382,
"GGY": 62792,
"GHA": 30417856,
"GIB": 33701,
"GIN": 12771246,
"GMB": 2347706,
"GNB": 1920922,
"GNQ": 1355986,
"GRC": 10716322,
"GRD": 112003,
"GRL": 56225,
"GTM": 16604026,
"GUM": 167294,
"GUY": 782766,
"HKG": 7507400,
"HND": 9746117,
"HRV": 4067500,
"HTI": 11263077,
"HUN": 9769949,
"IDN": 270625568,
"IMN": 84584,
"IND": 1366417754,
"IOA": 2387,
"IOT": 3000,
"IRL": 4941444,
"IRN": 82913906,
"IRQ": 39309783,
"ISL": 361313,
"ISR": 9053300,
"ITA": 60297396,
"JAM": 2948279,
"JEY": 107800,
"JOR": 10101694,
"JPN": 126264931,
"KAB": 36175,
"KAS": 6000,
"KAZ": 18513930,
"KEN": 52573973,
"KGZ": 6456900,
"KHM": 16486542,
"KIR": 117606,
"KNA": 52834,
"KOR": 51709098,
"KOS": 1794248,
"KWT": 4207083,
"LAO": 7169455,
"LBN": 6855713,
"LBR": 4937374,
"LBY": 6777452,
"LCA": 182790,
"LIE": 38019,
"LKA": 21803000,
"LSO": 2125268,
"LTU": 2786844,
"LUX": 619896,
"LVA": 1912789,
"MAC": 640445,
"MAF": 38002,
"MAR": 36471769,
"MCO": 38964,
"MDA": 2657637,
"MDG": 26969307,
"MDV": 530953,
"MEX": 127575529,
"MHL": 58791,
"MKD": 2083459,
"MLI": 19658031,
"MLT": 502653,
"MMR": 54045420,
"MNE": 622137,
"MNG": 3225167,
"MNP": 57216,
"MOZ": 30366036,
"MRT": 4525696,
"MSR": 4649,
"MUS": 1265711,
"MWI": 18628747,
"MYS": 31949777,
"NAM": 2494530,
"NCL": 287800,
"NER": 23310715,
"NFK": 2169,
"NGA": 200963599,
"NIC": 6545502,
"NIU": 1620,
"NLD": 17332850,
"NOR": 5347896,
"NPL": 28608710,
"NRU": 12581,
"NZL": 4917000,
"OMN": 4974986,
"PAK": 216565318,
"PAN": 4246439,
"PCN": 54,
"PER": 32510453,
"PGA": 100,
"PHL": 108116615,
"PLW": 18008,
"PNG": 8776109,
"POL": 37970874,
"PRI": 3193694,
"PRK": 25666161,
"PRT": 10269417,
"PRY": 7044636,
"PSX": 4685306,
"PYF": 279287,
"QAT": 2832067,
"ROU": 19356544,
"RUS": 144373535,
"RWA": 12626950,
"SAH": 603253,
"SAU": 34268528,
"SDN": 42813238,
"SDS": 11062113,
"SEN": 16296364,
"SGP": 5703569,
"SGS": 30,
"SHN": 4534,
"SLB": 669823,
"SLE": 7813215,
"SLV": 6453553,
"SMR": 33860,
"SOL": 5096159,
"SOM": 10192317,
"SPM": 5997,
"SRB": 6944975,
"STP": 215056,
"SUR": 581363,
"SVK": 5454073,
"SVN": 2087946,
"SWE": 10285453,
"SWZ": 1148130,
"SXM": 40733,
"SYC": 97625,
"SYR": 17070135,
"TCA": 38191,
"TCD": 15946876,
"TGO": 8082366,
"THA": 69625582,
"TJK": 9321018,
"TKM": 5942089,
"TLS": 1293119,
"TON": 104494,
"TTO": 1394973,
"TUN": 11694719,
"TUR": 83429615,
"TUV": 11646,
"TWN": 23568378,
"TZA": 58005463,
"UGA": 44269594,
"UKR": 44385155,
"UMI": 300,
"URY": 3461734,
"USA": 328239523,
"USG": 6000,
"UZB": 33580650,
"VAT": 825,
"VCT": 110589,
"VEN": 28515829,
"VGB": 30030,
"VIR": 106631,
"VNM": 96462106,
"VUT": 299882,
"WLF": 11558,
"WSB": 7850,
"WSM": 197097,
"YEM": 29161922,
"ZAF": 58558270,
"ZMB": 17861030,
"ZWE": 14645468
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/**
* scripts/build-region-geo.mjs — build the region **geo** manifest:
* `public/region-geo.json` = `{ "<regionKey>": { "area": <number>, "population": <number> }, ... }`.
*
* For every sub-region (region key, see `public/region-keys.json`) this records
* two facts the v0.28 per-region stats model (and its area×population weighting)
* needs:
* - `area` — the region's **total** surface area in km², from
* `@turf/area` on the FINAL generated feature
* (`public/regions.geo.json`) — turf reports square metres,
* so the raw value is divided by 1e6 — rounded to 2 decimals.
* - `population` — the region's share of its **parent country's** real
* population (Natural Earth 1:10m admin-0 `POP_EST`, from the
* committed `public/ne-admin0-pop.json`), distributed across
* the country's sub-regions **proportional to each region's
* area** (a first-order "uniform population density within a
* country" approximation — Natural Earth admin-1, which the
* region generator uses, carries no per-unit population).
*
* Population distribution uses **largest remainder** per country, so the sum of
* a country's sub-region populations is EXACTLY the country's POP_EST, and the
* sum over all 1,800 regions is EXACTLY the sum of the 177 repo countries'
* POP_EST (7,654,092,021) — no rounding drift.
*
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
* v0.17 note). The population source is the COMMITTED `public/ne-admin0-pop.json`
* (a small derived table produced once by `scripts/extract-admin0-pop.mjs` from
* the 13 MB Natural Earth admin-0 file, which we do NOT commit) — so this build
* is fully reproducible in CI.
*
* Output contract (asserted by scripts/region-geo.test.ts):
* - one entry for **every** region key, keyed identically to
* `public/region-keys.json` (same key set, 1,800 entries);
* - `area` is a finite number >= 0 (km², <= 2 decimals);
* - `population` is a non-negative integer;
* - the total population across all entries is a non-negative integer.
*
* Deterministic + idempotent: fixed inputs (the geo file, the key manifest, and
* the committed population table), pure turf, largest-remainder with a
* (remainder desc, key asc) tie-break, and stable sorted key order → a re-run
* rewrites a byte-identical file (the test re-runs this script and compares
* bytes).
*
* Usage: `node scripts/build-region-geo.mjs` (or `npm run build:region-geo`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import area from '@turf/area';
import { feature } from '@turf/helpers';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const round2 = (v) => Math.round(v * 100) / 100;
// --- load the inputs (plain parse) ------------------------------------------
const geoPath = path.join(root, 'public', 'regions.geo.json');
const keysPath = path.join(root, 'public', 'region-keys.json');
const popPath = path.join(root, 'public', 'ne-admin0-pop.json');
let geo, idToKey, popByCountry;
try {
geo = JSON.parse(readFileSync(geoPath, 'utf8'));
idToKey = JSON.parse(readFileSync(keysPath, 'utf8'));
popByCountry = JSON.parse(readFileSync(popPath, 'utf8'));
} catch (err) {
console.error(
`[build-region-geo] could not load the inputs (${geoPath}, ${keysPath}, ${popPath}). ` +
`Is public/ne-admin0-pop.json present? Regenerate it with scripts/extract-admin0-pop.mjs.`,
);
throw err;
}
/**
* One pass over the geo features: for each region key record its (unrounded)
* turf area and its parent ISO_A3, then group the keys by parent so the
* population can be distributed per country.
*/
const regionArea = new Map(); // key -> unrounded area (km²)
const regionParent = new Map(); // key -> parent ISO_A3
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
// @turf/area reports square METRES; the manifest stores km².
const a = area(feature(f.geometry)) / 1e6;
regionArea.set(key, a);
regionParent.set(key, typeof f.properties?.parent === 'string' ? f.properties.parent : '');
}
// Group region keys by parent country (file order is stable, but we sort for
// deterministic population distribution).
const keysByCountry = new Map(); // parent -> string[] of keys
for (const [key, parent] of regionParent) {
if (!parent) continue; // a region with no parent has no population source
const list = keysByCountry.get(parent);
if (list) list.push(key);
else keysByCountry.set(parent, [key]);
}
const population = new Map(); // key -> integer population
for (const [parent, keys] of keysByCountry) {
keys.sort(); // deterministic ordering for the tie-break
const P = Number.isFinite(popByCountry[parent]) ? Math.max(0, popByCountry[parent]) : 0;
const totalArea = keys.reduce((s, k) => s + (regionArea.get(k) || 0), 0);
if (P <= 0 || totalArea <= 0) {
for (const k of keys) population.set(k, 0);
continue;
}
// Largest remainder: floor each share, then hand out the leftover 1s to the
// regions with the largest fractional remainders (ties broken by key asc).
const shares = keys.map((k) => {
const a = regionArea.get(k) || 0;
const ideal = (P * a) / totalArea;
return { k, floor: Math.floor(ideal), rem: ideal - Math.floor(ideal) };
});
const distributed = shares.reduce((s, x) => s + x.floor, 0);
let deficit = P - distributed; // 0 <= deficit < keys.length
const order = shares
.map((x, i) => ({ i, rem: x.rem, k: x.k }))
.sort((a, b) => (b.rem - a.rem) || (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
for (let i = 0; i < order.length && deficit > 0; i++) {
shares[order[i].i].floor += 1;
deficit -= 1;
}
for (const x of shares) population.set(x.k, x.floor);
}
// --- assemble the manifest (sorted keys) -------------------------------------
const expectedKeys = Object.values(idToKey);
const manifest = {};
for (const k of [...expectedKeys].sort()) {
const a = regionArea.get(k);
const p = population.get(k);
if (a === undefined || p === undefined) {
throw new Error(`region key ${JSON.stringify(k)} has no computed area/population`);
}
manifest[k] = { area: round2(a), population: p };
}
// --- hard assertions (the test re-checks these against the committed file) ----
if (Object.keys(manifest).length !== expectedKeys.length) {
throw new Error(
`expected ${expectedKeys.length} region-geo entries (one per region key), got ${Object.keys(manifest).length}`,
);
}
let totalPop = 0;
let zeros = 0;
for (const [k, v] of Object.entries(manifest)) {
if (!Number.isFinite(v.area) || v.area < 0) throw new Error(`bad area for ${k}: ${JSON.stringify(v)}`);
if (!Number.isInteger(v.population) || v.population < 0) throw new Error(`bad population for ${k}: ${JSON.stringify(v)}`);
if (v.area === 0) zeros += 1;
totalPop += v.population;
}
// --- write idempotently (sorted keys, 2-space indent, trailing newline) ------
writeFileSync(path.join(root, 'public', 'region-geo.json'), JSON.stringify(manifest, null, 2) + '\n');
// --- summary ------------------------------------------------------------------
console.log(
`[build-region-geo] wrote public/region-geo.json — ${Object.keys(manifest).length} region keys` +
`${zeros ? ` (${zeros} zero-area regions)` : ''}, total population ${totalPop.toLocaleString('en-US')}`,
);
+78
View File
@@ -0,0 +1,78 @@
/**
* scripts/extract-admin0-pop.mjs — one-time extraction of the population
* SOURCE for the region-geo manifest: `public/ne-admin0-pop.json`.
*
* Output = `{ "<ADM0_A3>": <POP_EST>, ... }` — the estimated population
* (POP_EST) of every Natural Earth 1:10m **admin-0 country** code, keeping
* the **maximum** POP_EST seen for each code (Natural Earth ships a few
* duplicated / broken-out features per code, e.g. the `TLC` stubs, whose
* POP_EST is 0; the real country always wins by having the larger value).
* Only codes with a finite, positive POP_EST are recorded (a 0/null POP_EST
* has nothing to distribute). Keys are sorted; the file is written with
* 2-space indent + trailing newline (deterministic, committed).
*
* Why a separate committed file: `public/region-geo.json` (built by
* `build-region-geo.mjs`) needs a per-country population, but the raw
* Natural Earth GeoJSON (13 MB) is a *source* file we deliberately do NOT
* commit (see the AGENTS.md data-pipeline note). This script distils it
* into a tiny (a few KB) derived table that IS committed, so the region-geo
* builder stays fully reproducible in CI without the source present.
* Re-run only when the underlying Natural Earth data changes.
*
* The 1:10m admin-1 file (the one the region generator uses) carries NO
* population; the 1:10m admin-0 file does (uppercase `POP_EST` / `ADM0_A3`).
*
* Usage:
* NE_ADMIN0=/path/to/ne_10m_admin_0_countries.geojson node scripts/extract-admin0-pop.mjs
* (default source: /tmp/ne10m_admin0.geojson)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const src = process.env.NE_ADMIN0 || '/tmp/ne10m_admin0.geojson';
let raw;
try {
raw = readFileSync(src, 'utf8');
} catch (err) {
console.error(
`[extract-admin0-pop] could not read the Natural Earth admin-0 source at ${src}. ` +
`Download it (ne_10m_admin_0_countries.geojson) and re-run with NE_ADMIN0 pointing at it.`,
);
throw err;
}
const geo = JSON.parse(raw);
/** ADM0_A3 -> max finite, positive POP_EST. */
const byCode = new Map();
for (const f of geo.features ?? []) {
const p = f.properties ?? {};
const code = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : '';
const pop = typeof p.POP_EST === 'number' ? p.POP_EST : NaN;
if (!code || !Number.isFinite(pop) || pop <= 0) continue;
const prev = byCode.get(code);
if (prev === undefined || pop > prev) byCode.set(code, Math.round(pop));
}
if (byCode.size === 0) {
throw new Error('[extract-admin0-pop] no finite positive POP_EST found in the source — wrong file?');
}
// Deterministic: sorted keys, 2-space indent, trailing newline.
const keys = [...byCode.keys()].sort();
const manifest = {};
for (const k of keys) manifest[k] = byCode.get(k);
const out = path.join(root, 'public', 'ne-admin0-pop.json');
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
let sum = 0;
for (const v of byCode.values()) sum += v;
console.log(
`[extract-admin0-pop] wrote public/ne-admin0-pop.json — ${byCode.size} country codes, ` +
`total population ${sum.toLocaleString('en-US')} (Natural Earth 1:10m admin-0, max POP_EST per code)`,
);
+139
View File
@@ -0,0 +1,139 @@
/**
* scripts/region-geo.test.ts — validation for the committed
* `public/region-geo.json` (built by `scripts/build-region-geo.mjs`).
*
* The manifest maps each region key to its { area (km²), population } — the
* weighting source for the per-region stats model (v0.28, #46): a nation's
* four 0–100 stats are derived as the area×population-weighted median of the
* stats of the regions it owns. It is a load-bearing data artifact — this
* test pins its contract.
*
* The `area` is the turf area of the region's final geometry (km², 2 dp); the
* `population` is the country's Natural-Earth admin-0 `POP_EST` (from the
* committed `public/ne-admin0-pop.json`) distributed across its sub-regions
* proportional to area via largest-remainder, so every country's sub-regions
* sum EXACTLY to the country's total (and the global total is the sum over the
* 177 repo countries). This is a dependency-free test: it reads the JSON with
* plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull
* `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the
* v0.17 note). The determinism test re-runs the (turf `.mjs`) builder and
* compares bytes, matching that the builder is idempotent.
*/
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
interface GeoEntry {
area: number;
population: number;
}
// The 177 repo countries' admin-0 population totals (the committed derived
// source). Only the countries actually present in the region dataset appear.
const admin0 = readJson('public/ne-admin0-pop.json') as Record<string, number>;
// feature id -> region key
const manifest = readJson('public/region-keys.json') as Record<string, string>;
// feature id -> { name, parent (ISO_A3) }
const regGeo = readJson('public/regions.geo.json') as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
// region key -> parent ISO_A3 (via the key manifest + the geo feature parent).
const keyToParent: Record<string, string> = {};
for (const f of regGeo.features ?? []) {
const key = manifest[String(f.id)];
const parent = f.properties?.parent;
if (key && parent) keyToParent[key] = parent;
}
const geo = readJson('public/region-geo.json') as Record<string, GeoEntry>;
const keys = Object.values(manifest);
describe('region-geo.json', () => {
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
expect(new Set(Object.keys(geo))).toEqual(new Set(keys));
expect(Object.keys(geo)).toHaveLength(1800);
});
it('is keyed in sorted (deterministic) order', () => {
const ks = Object.keys(geo);
expect(ks).toEqual([...ks].sort());
});
it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => {
for (const [k, v] of Object.entries(geo)) {
expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true);
expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true);
expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true);
// area is recorded to at most 2 decimal places (km²)
expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true);
expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true);
expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true);
}
});
it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => {
// The distribution preserved each country's total exactly, so the grand
// total over all sub-regions is the sum over the repo countries only.
const total = Object.values(geo).reduce((s, v) => s + v.population, 0);
expect(total).toBe(7654092021);
});
it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => {
// For every parent country present in the region dataset, the sum of its
// sub-regions' populations must equal that country's admin-0 POP_EST
// EXACTLY — this is the invariant the largest-remainder distribution
// guarantees (no population is created or lost by the split).
const byParent: Record<string, number> = {};
for (const [k, v] of Object.entries(geo)) {
const p = keyToParent[k];
if (!p) throw new Error(`no parent for region key ${k}`);
byParent[p] = (byParent[p] ?? 0) + v.population;
}
expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries
for (const [p, sum] of Object.entries(byParent)) {
expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined();
expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]);
}
});
it('spot-checks well-known regions for exact area + population', () => {
const is = (k: string, area: number, population: number) => {
const v = geo[k];
expect(v, `missing geo for ${k}`).toBeDefined();
expect(v!.area, `area for ${k}`).toBe(area);
expect(v!.population, `population for ${k}`).toBe(population);
};
is('california-kikh', 413329.21, 14184195); // US-CA
is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core
is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41)
is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41)
is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31)
is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31)
is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain)
});
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
const file = path.join(root, 'public', 'region-geo.json');
const before = readFileSync(file, 'utf8');
// The builder is cwd-independent (it resolves the repo root from its own
// location) and idempotent: same input files (regions.geo.json +
// region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding
// + stable (sorted) key order → identical bytes.
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], {
cwd: root,
stdio: 'pipe',
});
const after = readFileSync(file, 'utf8');
expect(after).toBe(before);
});
});
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+2
View File
@@ -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.
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest';
import {
deriveNationStats,
migrateRegionStats,
seedRegionStats,
weightedMedian,
} from '../shared/protocol';
import type { GameState, Nation, NationStats } from '../shared/types';
// --- Test fixtures -----------------------------------------------------------
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
military,
economy,
stability,
population,
});
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
function mkState(
nations: Record<string, Nation>,
regionStats?: Record<string, unknown>,
): GameState {
const base: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: Object.keys(nations)[0] ?? '',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats: {} as GameState['regionStats'],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
return base;
}
// --- weightedMedian ----------------------------------------------------------
describe('weightedMedian', () => {
it('returns 0 for an empty input or zero total weight', () => {
expect(weightedMedian([], [])).toBe(0);
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
});
it('returns the single value when it is the only weighted input', () => {
expect(weightedMedian([42], [7])).toBe(42);
});
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
// The invariant seeding + legacy migration rely on: equal values → that value,
// regardless of the (arbitrary) weights.
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
// and a constant shift shifts the result by the same constant
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
});
it('picks the value at the first point the cumulative weight reaches half the total', () => {
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
// an odd middle: the true middle value
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
// the heavier value dominates
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
});
it('treats non-finite or non-positive weights as excluded (0)', () => {
// NaN weight on 10, positive on 20 → only 20 counts
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
// negative weight on 20, positive on 10 → only 10 counts
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
});
});
// --- deriveNationStats -------------------------------------------------------
describe('deriveNationStats', () => {
const regionStats = {
a: S(50, 50, 50, 50),
b: S(90, 90, 90, 90),
};
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
it('weights by area×population: the larger region dominates the aggregate', () => {
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
// equal weights 1,1 → the lower (a=50) is reached at half-total first
const out = deriveNationStats(['a', 'b'], regionStats);
expect(out).toEqual(S(50, 50, 50, 50));
// same when geoOf is provided but returns undefined for the keys (missing geo)
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
expect(out2).toEqual(S(50, 50, 50, 50));
});
it('skips a region key that has no stats entry', () => {
// b has no entry → only a is counted → a's (50) values
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
expect(out).toEqual(S(50, 50, 50, 50));
});
it('returns zero stats when none of the owned regions has a stats entry', () => {
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
expect(out).toEqual(S(0, 0, 0, 0));
});
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
});
it('treats zero/negative area or population as a fallback weight of 1', () => {
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
});
// --- seedRegionStats ---------------------------------------------------------
describe('seedRegionStats', () => {
it('copies each owner\'s stats to every region it owns', () => {
const nations = {
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
};
expect(seedRegionStats(nations)).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(95, 70, 75, 90),
});
});
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
const nations = {
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
};
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
});
it('returns an empty table for nations with no territory', () => {
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
expect(seedRegionStats(nations)).toEqual({});
});
});
// --- migrateRegionStats ------------------------------------------------------
describe('migrateRegionStats', () => {
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
const state = mkState(
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
);
const out = migrateRegionStats(state);
expect(out).toBe(state); // same reference: untouched
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
});
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
});
// sanity: we built a legacy row
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
const out = migrateRegionStats(state, (k) =>
k === 'k1' ? { area: 100, population: 1000 }
: k === 'k2' ? { area: 1, population: 1 }
: k === 'k3' ? { area: 9, population: 9 }
: { area: 50, population: 500 });
// each owned region is seeded with a copy of its owner's stats…
expect(out.regionStats).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(80, 70, 60, 50),
k4: S(95, 70, 75, 90),
});
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
// identical values is that value, regardless of the (uneven) weights.
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
// the original row is NOT mutated
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
});
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
});
const out = migrateRegionStats(state);
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
expect('uk' in (out.regionStats ?? {})).toBe(false);
});
});
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+26
View File
@@ -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>;
} }
/** /**