Move each nation's four 0–100 stats to per-region storage (GameState.regionStats) and make Nation.stats a derived area×population-weighted median of the stats of the regions it owns — v0.28.0. PR 1 of 2: this PR ships the storage model, the public/region-geo.json manifest, the pure derivation helpers, the engine maintenance/recompute, and the one-time legacy migration; the per-region LLM lever (the model editing individual region stats) is PR 2 and is not part of this change. Deliberately not deployed until PR 2 ships.
Scope
Data (new committed manifests)
public/region-geo.json — { [regionKey]: { area, population } }, 1,800 entries, sorted keys, deterministic/byte-identical re-run. area = @turf/area(feature) / 1e6 km² (2dp) of the final regions.geo.json feature; population integer.
public/ne-admin0-pop.json — the committed population source: Natural Earth 1:10m admin-0 POP_EST (max per code), 249 ISO_A3 codes, total 7,677,059,722. Distilled once by the one-time scripts/extract-admin0-pop.mjs; the 13 MB raw source is never committed.
Population is spread from each country's admin-0 POP_EST to its sub-regions proportional to area by largest-remainder, so each country's sub-regions sum exactly to its POP_EST (verified: 0 mismatches over 177 countries; global total 7,654,092,021).
scripts/build-region-geo.mjs (turf, .mjs — allowed in scripts/ only) reads regions.geo.json + region-keys.json + ne-admin0-pop.json; fails clearly if the source is missing. New npm scripts: build:region-geo, extract:admin0-pop.
Types & pure helpers
shared/types.ts: RegionStats (the four 0–100 stats) and required GameState.regionStats: Record<string, RegionStats> ({} at game creation; released / 0-owner / 2+-owner keys simply have no entry; legacy rows migrated, never wiped).
shared/protocol.ts (pure, no runtime deps):
weightedMedian(values, weights) — value at the first point where cumulative weight reaches half the total; translation-invariant (median of identical values = that value); empty/zero-weight input → 0.
deriveNationStats(regionKeys, regionStats, geoOf?) — per-stat area×population-weighted median over the nation's owned regions; per-region weight (area>0?area:1)×(population>0?population:1) (missing geo → weight 1 → unweighted median); a key with no regionStats entry is skipped; no entered region → zero stats (caller's 0-region guard keeps n.stats).
seedRegionStats(nations) — every owned key gets a copy of its owner's stats (first owner wins), so a fresh nation's regions start identical.
migrateRegionStats(state, geoOf?) — a row that already has regionStats is returned unchanged (no recompute on load → no drift); a legacy row is seeded + recomputed (0-region guard); returns a new state, never throws.
Server
server/regions.ts (turf-free): loads region-geo.json (probes public/ then dist/, optional — degrades to unweighted if absent) and exposes geoOf(key) + hasGeo().
Game creation (server/index.ts): seeds regionStats via seedRegionStats; the jump route wires geoOf into runTurn only when hasGeo().
server/db.ts: load runs migrateRegionStats (without geoOf — weight-invariant for the identical-value seed).
server/engine.tsapplyTurnResult (new optional geoOf param, passed through runTurn) maintains the table in 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 write to n.statsand to every region the nation owns; then every nation with ≥1 entered region gets stats recomputed as its regions' weighted median (a 0-region / no-entered-region nation keeps its directly-maintained stats). This ordering + guard is why the existing unit and e2e assertions pass unchanged.
What is NOT in this PR
No per-region LLM lever: the LLM prompt is unchanged (it still only sees/emits nation-level stateChanges); the per-region lever lands in PR 2.
Nation ops (rename/split/merge) are not modified: they reassign existing keys, and a new split nation's baseline stats self-correct on the next recompute.
The stub is untouched (it never emits per-region data; e2e is unaffected).
No deploy — production is updated when PR 2 ships.
Fresh games seeded; legacy rows migrated without drift; v0.28+ rows untouched on load.
Transfers/releases/re-claims maintain regionStats; every nation's stats are the derived weighted median.
Unit tests cover the helpers (server/protocol.test.ts), the engine path, migration, loader, and the manifest invariants (scripts/region-geo.test.ts); npm test + typecheck + lint + build green; e2e green in CI.
## Goal
Move each nation's four 0–100 stats to **per-region** storage (`GameState.regionStats`) and make `Nation.stats` a **derived area×population-weighted median** of the stats of the regions it owns — v0.28.0. **PR 1 of 2**: this PR ships the storage model, the `public/region-geo.json` manifest, the pure derivation helpers, the engine maintenance/recompute, and the one-time legacy migration; the per-region LLM lever (the model editing individual region stats) is **PR 2** and is not part of this change. **Deliberately not deployed** until PR 2 ships.
## Scope
### Data (new committed manifests)
- `public/region-geo.json` — `{ [regionKey]: { area, population } }`, 1,800 entries, sorted keys, deterministic/byte-identical re-run. `area` = `@turf/area(feature) / 1e6` km² (2dp) of the final `regions.geo.json` feature; `population` integer.
- `public/ne-admin0-pop.json` — the **committed population source**: Natural Earth 1:10m admin-0 `POP_EST` (max per code), 249 ISO_A3 codes, total 7,677,059,722. Distilled once by the one-time `scripts/extract-admin0-pop.mjs`; the 13 MB raw source is never committed.
- Population is spread from each country's admin-0 `POP_EST` to its sub-regions **proportional to area by largest-remainder**, so each country's sub-regions sum exactly to its `POP_EST` (verified: 0 mismatches over 177 countries; global total 7,654,092,021).
- `scripts/build-region-geo.mjs` (turf, `.mjs` — allowed in `scripts/` only) reads `regions.geo.json` + `region-keys.json` + `ne-admin0-pop.json`; fails clearly if the source is missing. New npm scripts: `build:region-geo`, `extract:admin0-pop`.
### Types & pure helpers
- `shared/types.ts`: `RegionStats` (the four 0–100 stats) and required `GameState.regionStats: Record<string, RegionStats>` (`{}` at game creation; released / 0-owner / 2+-owner keys simply have no entry; legacy rows migrated, never wiped).
- `shared/protocol.ts` (pure, no runtime deps):
- `weightedMedian(values, weights)` — value at the first point where cumulative weight reaches half the total; translation-invariant (median of identical values = that value); empty/zero-weight input → 0.
- `deriveNationStats(regionKeys, regionStats, geoOf?)` — per-stat area×population-weighted median over the nation's owned regions; per-region weight `(area>0?area:1)×(population>0?population:1)` (missing geo → weight 1 → unweighted median); a key with no `regionStats` entry is skipped; no entered region → zero stats (caller's 0-region guard keeps `n.stats`).
- `seedRegionStats(nations)` — every owned key gets a **copy** of its owner's stats (first owner wins), so a fresh nation's regions start identical.
- `migrateRegionStats(state, geoOf?)` — a row that already has `regionStats` is returned **unchanged** (no recompute on load → no drift); a legacy row is seeded + recomputed (0-region guard); returns a new state, never throws.
### Server
- `server/regions.ts` (turf-free): loads `region-geo.json` (probes `public/` then `dist/`, optional — degrades to unweighted if absent) and exposes `geoOf(key)` + `hasGeo()`.
- Game creation (`server/index.ts`): seeds `regionStats` via `seedRegionStats`; the jump route wires `geoOf` into `runTurn` only when `hasGeo()`.
- `server/db.ts`: `load` runs `migrateRegionStats` (without `geoOf` — weight-invariant for the identical-value seed).
- `server/engine.ts` `applyTurnResult` (new optional `geoOf` param, passed through `runTurn`) maintains the table in 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` 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 / no-entered-region nation keeps its directly-maintained `stats`). This ordering + guard is why the existing unit and e2e assertions pass **unchanged**.
### What is NOT in this PR
- No per-region LLM lever: the LLM prompt is **unchanged** (it still only sees/emits nation-level `stateChanges`); the per-region lever lands in PR 2.
- Nation ops (rename/split/merge) are not modified: they reassign existing keys, and a new split nation's baseline stats self-correct on the next recompute.
- The stub is untouched (it never emits per-region data; e2e is unaffected).
- **No deploy** — production is updated when PR 2 ships.
## Acceptance
- `region-geo.json` + `ne-admin0-pop.json` committed, deterministic (byte-identical re-run), per-country population totals preserved exactly.
- Fresh games seeded; legacy rows migrated without drift; v0.28+ rows untouched on load.
- Transfers/releases/re-claims maintain `regionStats`; every nation's stats are the derived weighted median.
- Unit tests cover the helpers (`server/protocol.test.ts`), the engine path, migration, loader, and the manifest invariants (`scripts/region-geo.test.ts`); `npm test` + typecheck + lint + build green; e2e green in CI.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Goal
Move each nation's four 0–100 stats to per-region storage (
GameState.regionStats) and makeNation.statsa derived area×population-weighted median of the stats of the regions it owns — v0.28.0. PR 1 of 2: this PR ships the storage model, thepublic/region-geo.jsonmanifest, the pure derivation helpers, the engine maintenance/recompute, and the one-time legacy migration; the per-region LLM lever (the model editing individual region stats) is PR 2 and is not part of this change. Deliberately not deployed until PR 2 ships.Scope
Data (new committed manifests)
public/region-geo.json—{ [regionKey]: { area, population } }, 1,800 entries, sorted keys, deterministic/byte-identical re-run.area=@turf/area(feature) / 1e6km² (2dp) of the finalregions.geo.jsonfeature;populationinteger.public/ne-admin0-pop.json— the committed population source: Natural Earth 1:10m admin-0POP_EST(max per code), 249 ISO_A3 codes, total 7,677,059,722. Distilled once by the one-timescripts/extract-admin0-pop.mjs; the 13 MB raw source is never committed.POP_ESTto its sub-regions proportional to area by largest-remainder, so each country's sub-regions sum exactly to itsPOP_EST(verified: 0 mismatches over 177 countries; global total 7,654,092,021).scripts/build-region-geo.mjs(turf,.mjs— allowed inscripts/only) readsregions.geo.json+region-keys.json+ne-admin0-pop.json; fails clearly if the source is missing. New npm scripts:build:region-geo,extract:admin0-pop.Types & pure helpers
shared/types.ts:RegionStats(the four 0–100 stats) and requiredGameState.regionStats: Record<string, RegionStats>({}at game creation; released / 0-owner / 2+-owner keys simply have no entry; legacy rows migrated, never wiped).shared/protocol.ts(pure, no runtime deps):weightedMedian(values, weights)— value at the first point where cumulative weight reaches half the total; translation-invariant (median of identical values = that value); empty/zero-weight input → 0.deriveNationStats(regionKeys, regionStats, geoOf?)— per-stat area×population-weighted median over the nation's owned regions; per-region weight(area>0?area:1)×(population>0?population:1)(missing geo → weight 1 → unweighted median); a key with noregionStatsentry is skipped; no entered region → zero stats (caller's 0-region guard keepsn.stats).seedRegionStats(nations)— every owned key gets a copy of its owner's stats (first owner wins), so a fresh nation's regions start identical.migrateRegionStats(state, geoOf?)— a row that already hasregionStatsis returned unchanged (no recompute on load → no drift); a legacy row is seeded + recomputed (0-region guard); returns a new state, never throws.Server
server/regions.ts(turf-free): loadsregion-geo.json(probespublic/thendist/, optional — degrades to unweighted if absent) and exposesgeoOf(key)+hasGeo().server/index.ts): seedsregionStatsviaseedRegionStats; the jump route wiresgeoOfintorunTurnonly whenhasGeo().server/db.ts:loadrunsmigrateRegionStats(withoutgeoOf— weight-invariant for the identical-value seed).server/engine.tsapplyTurnResult(new optionalgeoOfparam, passed throughrunTurn) maintains the table in 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 currentstats; nation-levelstateChangeswrite ton.statsand to every region the nation owns; then every nation with ≥1 entered region getsstatsrecomputed as its regions' weighted median (a 0-region / no-entered-region nation keeps its directly-maintainedstats). This ordering + guard is why the existing unit and e2e assertions pass unchanged.What is NOT in this PR
stateChanges); the per-region lever lands in PR 2.Acceptance
region-geo.json+ne-admin0-pop.jsoncommitted, deterministic (byte-identical re-run), per-country population totals preserved exactly.regionStats; every nation's stats are the derived weighted median.server/protocol.test.ts), the engine path, migration, loader, and the manifest invariants (scripts/region-geo.test.ts);npm test+ typecheck + lint + build green; e2e green in CI.