Per-region stats: area×population-weighted-median Nation.stats (v0.28.0, PR 1 of 2) #46

Closed
opened 2026-10-15 02:18:08 +02:00 by kamilkrzywda · 0 comments
Owner

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.
## 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.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OpenCode/History#46