v0.28.0 (#46): per-region stats — area×population-weighted-median Nation.stats (PR 1 of 2)
CI / build + unit + e2e (pull_request) Successful in 2m34s

This commit is contained in:
2026-10-15 00:17:19 +00:00
parent 327fffb789
commit 22805a3be6
19 changed files with 8743 additions and 29 deletions
+38
View File
@@ -26,6 +26,11 @@ function makeState(id: string): GameState {
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) a v0.28+ save carries per-region stats; the fake 'GBR'
// region here holds a copy of the uk's (single-region) stats.
regionStats: {
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
},
};
}
@@ -109,6 +114,39 @@ describe('openDb', () => {
db.close();
});
it('(v0.28 #46) a legacy row saved before the regionStats field existed loads with it seeded per region and stats recomputed', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { regionStats?: Record<string, unknown> }).regionStats; // a pre-v0.28 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
// each owned region is seeded with a copy of its owner's stats…
expect(loaded!.regionStats).toEqual({
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
});
// …and a single-region nation's derived stats equal the seeded ones
// (the weighted median of identical values is that value).
expect(loaded!.nations.uk.stats).toEqual({ military: 80, economy: 70, stability: 60, population: 50 });
db.close();
});
it('(v0.28 #46) a v0.28+ row (regionStats present) loads UNCHANGED — no recompute on load', () => {
const db = openDb(':memory:');
const s = makeState('current');
// Diverge one region's stats from the nation level to prove load does not
// recompute (a v0.28+ save keeps its stored derived stats exactly).
s.regionStats = { GBR: { military: 99, economy: 70, stability: 60, population: 50 } };
s.nations = { uk: { ...s.nations.uk, stats: { military: 42, economy: 70, stability: 60, population: 50 } } };
db.save(s);
const loaded = db.load('current');
expect(loaded).not.toBeNull();
expect(loaded!.regionStats).toEqual({ GBR: { military: 99, economy: 70, stability: 60, population: 50 } });
// the nation's stats are NOT recomputed from the region on load (they stay 42)
expect(loaded!.nations.uk.stats.military).toBe(42);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+9 -1
View File
@@ -2,6 +2,7 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
export interface Db {
@@ -39,7 +40,7 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
try {
const state = JSON.parse(row.data) as GameState;
let state = JSON.parse(row.data) as GameState;
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
// with it defaulted: a legacy game has no no-man's-land regions.
if (state.unowned === undefined) state.unowned = [];
@@ -48,6 +49,13 @@ export function openDb(filePath: string): Db {
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
// nation-level stats and the nations' stats are recomputed as the
// (uniform-weight) area×population-weighted median, so no data is lost
// and a single-region nation keeps exactly its old stats.
state = migrateRegionStats(state);
return state;
} catch {
return null;
+219 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { GameState, MarkerType, Preset, RoundItem } from '../shared/types';
import { makeEvent, markerId, MAX_MARKERS, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub';
import {
@@ -71,6 +71,11 @@ const preset: Preset = {
};
function baseState(): GameState {
const nations: GameState['nations'] = {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
};
return {
id: 'g1',
presetId: 'test',
@@ -78,17 +83,16 @@ function baseState(): GameState {
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) every owned region starts as a copy of its owner's stats,
// so a fresh state's derived stats equal its nation-level stats.
regionStats: seedRegionStats(nations),
};
}
@@ -1908,3 +1912,211 @@ describe('markers (point placement, v0.27 #44)', () => {
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 {
addDays,
clampStat,
deriveNationStats,
fnv1a32,
isDateLike,
isMarkerType,
@@ -592,15 +593,26 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* deterministic offset; a spec whose key has no known centroid is dropped.
* Without it no marker can be placed (all adds are dropped), which keeps the
* engine pure and lets tests inject their own data.
*
* `geoOf` (v0.28, #46) resolves a region key to its area + population
* (from public/region-geo.json via server/regions.ts), used to weight the
* end-of-turn recompute of each nation's derived stats as the
* area×population-weighted median of its regions' stats. Without it the
* recompute degrades to an unweighted median (every region weight 1) — still
* region-consistent, just unweighted. The engine stays pure either way.
*/
export function applyTurnResult(
state: GameState,
result: TurnResult,
validKeys?: ReadonlySet<string>,
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
// (v0.28, #46) per-region stats table; a legacy/corrupt clone without the
// field starts empty (the turn then re-inits claimed regions as it goes).
next.regionStats = next.regionStats ?? {};
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -609,11 +621,6 @@ export function applyTurnResult(
for (const ev of result.events) {
next.events.push(makeEvent(ev, actingTurn, next.date));
}
for (const c of result.stateChanges) {
const n = next.nations[c.nation];
if (!n) continue;
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
}
for (const r of result.relations) {
const a = next.nations[r.a];
const b = next.nations[r.b];
@@ -663,6 +670,10 @@ export function applyTurnResult(
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
unowned.add(key);
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
// (v0.28, #46) a released region leaves no-man's land ownerless — its
// per-region stats leave the table with it (no man's land carries none;
// a later re-claim re-inits the entry from the gainer, see below).
delete next.regionStats[key];
}
// (2) Claims against the post-release ownership snapshot.
@@ -709,10 +720,53 @@ export function applyTurnResult(
}
next.nations[gainer].territory.push(key);
unowned.delete(key); // re-claimed: the key leaves no man's land
// (v0.28, #46) a transferred region keeps its own per-region stats (the
// conquest is what makes a nation's regions diverge); only a re-claimed
// region that has no entry left (it was released earlier this turn) is
// re-inited as a copy of the gainer's current stats, so the recompute
// below never sees a hole in the table.
if (!next.regionStats[key]) next.regionStats[key] = { ...next.nations[gainer].stats };
}
next.unowned = [...unowned];
// --- Per-region stats (v0.28, #46): nation-level stateChanges re-applied to
// the regions, then every nation's stats recomputed as the derived
// area×population-weighted median of its regions ---
//
// (a) A nation-level stat change (`stateChanges`) is re-interpreted as
// applying to EVERY region the nation owns: each owned region's copy of
// that stat gets the same `set`/`add` (clamped to 0–100), and the
// nation-level stat is updated too (it is kept as-is for nations the
// recompute below does not touch — see (b) — and overwritten for the
// rest, where the regions are the source of truth).
//
// (b) RECOMPUTE: every nation's `stats` is re-derived as the weighted
// median of its owned regions' stats (via deriveNationStats, weighted
// by area×population when `geoOf` is available, unweighted otherwise).
// Two guards keep degenerate states sane: a nation with NO regions
// keeps its stats untouched (it has no regions to aggregate), and a
// nation none of whose regions has a stats entry keeps its stats
// too (deriveNationStats would return zeros, which would be wrong).
// This recompute is what makes a conquered region's foreign stats
// blend into the conqueror's aggregate and keeps the derived stats in
// lockstep with the per-region table across the turn's changes.
for (const c of result.stateChanges) {
const n = next.nations[c.nation];
if (!n) continue;
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped (recompute does the same)
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
}
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
}
for (const n of Object.values(next.nations)) {
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -1115,8 +1169,16 @@ export async function runTurn(opts: {
* the existing markers' owners still get recomputed.
*/
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
/**
* (v0.28, #46) Region-key -> area/population resolver (from
* public/region-geo.json). Present, the end-of-turn recompute of each
* nation's derived stats weights each owned region by area×population;
* absent, the recompute degrades to an unweighted (uniform) median —
* still region-consistent, just unweighted.
*/
geoOf?: (key: string) => { area: number; population: number } | undefined;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
@@ -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) -----------------------------------------------------
+14 -2
View File
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import { deepClone, seedRegionStats } from '../shared/protocol';
import type {
AddActionRequest,
AddDiplomacyRequest,
@@ -22,7 +22,7 @@ import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { allTerritoryKeys, 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 { openDb, type Db } from './db';
@@ -136,11 +136,18 @@ app.post('/api/games', (req: Request, res) => {
pendingNationOps: [],
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
markers: [],
// (v0.28, #46) Per-region stats are seeded below, once territories hold
// real region keys — each region starts as a copy of its owner's initial
// stats, so the derived aggregate equals the nation's stats on turn 1.
regionStats: {},
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
// granularity from the very first turn. Preset JSONs stay country-level.
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
// region key gets its owner's initial stats (see seedRegionStats).
state.regionStats = seedRegionStats(state.nations);
db.save(state);
res.json({ game: state });
});
@@ -295,6 +302,11 @@ app.post('/api/games/:id/jump', async (req, res) => {
// centroid (without it, marker adds are dropped; existing markers are
// still maintained).
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
// hand the engine the resolver so the end-of-turn recompute weights each
// owned region by area×population (without it the aggregate degrades to
// an unweighted median — still region-consistent, just unweighted).
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
});
db.save(state);
broadcast(id, 'turn', { state, result });
+2
View File
@@ -50,6 +50,8 @@ const state: GameState = {
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
regionStats: {},
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest';
import {
deriveNationStats,
migrateRegionStats,
seedRegionStats,
weightedMedian,
} from '../shared/protocol';
import type { GameState, Nation, NationStats } from '../shared/types';
// --- Test fixtures -----------------------------------------------------------
const S = (military: number, economy: number, stability: number, population: number): NationStats => ({
military,
economy,
stability,
population,
});
function nation(id: string, name: string, stats: NationStats, territory: string[]): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
function mkState(
nations: Record<string, Nation>,
regionStats?: Record<string, unknown>,
): GameState {
const base: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: Object.keys(nations)[0] ?? '',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats: {} as GameState['regionStats'],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
return base;
}
// --- weightedMedian ----------------------------------------------------------
describe('weightedMedian', () => {
it('returns 0 for an empty input or zero total weight', () => {
expect(weightedMedian([], [])).toBe(0);
expect(weightedMedian([5, 6], [0, 0])).toBe(0); // zero weights
expect(weightedMedian([5, 6], [NaN, -1])).toBe(0); // non-finite / non-positive → 0
});
it('returns the single value when it is the only weighted input', () => {
expect(weightedMedian([42], [7])).toBe(42);
});
it('the median of a set of IDENTICAL values is that value (translation-invariant)', () => {
// The invariant seeding + legacy migration rely on: equal values → that value,
// regardless of the (arbitrary) weights.
expect(weightedMedian([7, 7, 7], [1, 5, 9])).toBe(7);
expect(weightedMedian([0, 0], [10, 20])).toBe(0);
expect(weightedMedian([100, 100, 100], [1, 1, 1000])).toBe(100);
// and a constant shift shifts the result by the same constant
expect(weightedMedian([107, 107, 107], [1, 5, 9])).toBe(weightedMedian([7, 7, 7], [1, 5, 9]) + 100);
});
it('picks the value at the first point the cumulative weight reaches half the total', () => {
// two equal weights: the lower value is reached first (cum 1 of 2 ≥ 1)
expect(weightedMedian([10, 20], [1, 1])).toBe(10);
// an odd middle: the true middle value
expect(weightedMedian([10, 20, 30], [1, 1, 1])).toBe(20);
// the heavier value dominates
expect(weightedMedian([10, 20], [1, 3])).toBe(20);
expect(weightedMedian([10, 20], [3, 1])).toBe(10);
});
it('treats non-finite or non-positive weights as excluded (0)', () => {
// NaN weight on 10, positive on 20 → only 20 counts
expect(weightedMedian([10, 20], [NaN, 1])).toBe(20);
// negative weight on 20, positive on 10 → only 10 counts
expect(weightedMedian([10, 20], [1, -5])).toBe(10);
});
});
// --- deriveNationStats -------------------------------------------------------
describe('deriveNationStats', () => {
const regionStats = {
a: S(50, 50, 50, 50),
b: S(90, 90, 90, 90),
};
const geo: Record<string, { area: number; population: number }> = { a: { area: 100, population: 1000 }, b: { area: 1000, population: 1000 } };
it('weights by area×population: the larger region dominates the aggregate', () => {
// a: weight 100×1000=100000, b: weight 1000×1000=1000000 → b (90) dominates
const out = deriveNationStats(['a', 'b'], regionStats, (k) => geo[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
it('degrades to a uniform (unweighted) median without geo: the lower value is reached first', () => {
// equal weights 1,1 → the lower (a=50) is reached at half-total first
const out = deriveNationStats(['a', 'b'], regionStats);
expect(out).toEqual(S(50, 50, 50, 50));
// same when geoOf is provided but returns undefined for the keys (missing geo)
const out2 = deriveNationStats(['a', 'b'], regionStats, () => undefined);
expect(out2).toEqual(S(50, 50, 50, 50));
});
it('skips a region key that has no stats entry', () => {
// b has no entry → only a is counted → a's (50) values
const out = deriveNationStats(['a', 'b'], { a: S(50, 50, 50, 50) }, (k) => geo[k]);
expect(out).toEqual(S(50, 50, 50, 50));
});
it('returns zero stats when none of the owned regions has a stats entry', () => {
const out = deriveNationStats(['a', 'b'], {}, (k) => geo[k]);
expect(out).toEqual(S(0, 0, 0, 0));
});
it('a single region yields exactly that region\'s stats (the seeding invariant)', () => {
expect(deriveNationStats(['a'], regionStats, (k) => geo[k])).toEqual(S(50, 50, 50, 50));
expect(deriveNationStats(['a'], regionStats)).toEqual(S(50, 50, 50, 50));
});
it('treats zero/negative area or population as a fallback weight of 1', () => {
// a has area 0 (weight falls back to 1×pop), b has full weight → b dominates
const g: Record<string, { area: number; population: number }> = { a: { area: 0, population: 1000 }, b: { area: 1000, population: 1000 } };
const out = deriveNationStats(['a', 'b'], regionStats, (k) => g[k]);
expect(out).toEqual(S(90, 90, 90, 90));
});
});
// --- seedRegionStats ---------------------------------------------------------
describe('seedRegionStats', () => {
it('copies each owner\'s stats to every region it owns', () => {
const nations = {
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k3']),
};
expect(seedRegionStats(nations)).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(95, 70, 75, 90),
});
});
it('gives the first owner\'s stats a key owned by two or more nations (corrupt)', () => {
const nations = {
a: nation('a', 'A', S(10, 10, 10, 10), ['k1']),
b: nation('b', 'B', S(90, 90, 90, 90), ['k1']),
};
expect(seedRegionStats(nations)).toEqual({ k1: S(10, 10, 10, 10) });
});
it('returns an empty table for nations with no territory', () => {
const nations = { a: nation('a', 'A', S(10, 10, 10, 10), []) };
expect(seedRegionStats(nations)).toEqual({});
});
});
// --- migrateRegionStats ------------------------------------------------------
describe('migrateRegionStats', () => {
it('returns a v0.28+ row (regionStats present) UNCHANGED — same reference, no recompute', () => {
const state = mkState(
{ uk: nation('uk', 'UK', S(42, 70, 60, 50), ['k1']) },
{ k1: S(99, 70, 60, 50) }, // deliberately divergent from the nation stats
);
const out = migrateRegionStats(state);
expect(out).toBe(state); // same reference: untouched
expect(out.regionStats).toEqual({ k1: S(99, 70, 60, 50) });
expect(out.nations.uk.stats).toEqual(S(42, 70, 60, 50)); // NOT recomputed from the region
});
it('migrates a legacy row (regionStats absent): seeds each owned region and preserves stats', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), ['k1', 'k2', 'k3']),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k4']),
});
// sanity: we built a legacy row
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
const out = migrateRegionStats(state, (k) =>
k === 'k1' ? { area: 100, population: 1000 }
: k === 'k2' ? { area: 1, population: 1 }
: k === 'k3' ? { area: 9, population: 9 }
: { area: 50, population: 500 });
// each owned region is seeded with a copy of its owner's stats…
expect(out.regionStats).toEqual({
k1: S(80, 70, 60, 50),
k2: S(80, 70, 60, 50),
k3: S(80, 70, 60, 50),
k4: S(95, 70, 75, 90),
});
// …and the (multi-region) nation's stats are PRESERVED: the weighted median of
// identical values is that value, regardless of the (uneven) weights.
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50));
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90));
// the original row is NOT mutated
expect((state as { regionStats?: unknown }).regionStats).toBeUndefined();
expect(state.nations.uk.stats).toEqual(S(80, 70, 60, 50));
});
it('a legacy 0-region nation keeps its stats (no recompute, no entries)', () => {
const state = mkState({
uk: nation('uk', 'UK', S(80, 70, 60, 50), []),
de: nation('de', 'DE', S(95, 70, 75, 90), ['k1']),
});
const out = migrateRegionStats(state);
expect(out.nations.uk.stats).toEqual(S(80, 70, 60, 50)); // unchanged
expect(out.nations.de.stats).toEqual(S(95, 70, 75, 90)); // single region → preserved
expect(out.regionStats).toEqual({ k1: S(95, 70, 75, 90) });
expect('uk' in (out.regionStats ?? {})).toBe(false);
});
});
+38
View File
@@ -7,8 +7,10 @@ import {
allTerritoryKeys,
centroidOf,
expandCountriesToKeys,
geoOf,
hasAdjacency,
hasCentroids,
hasGeo,
hasRegionData,
loadRegions,
neighboursOf,
@@ -106,6 +108,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
unowned: [],
pendingNationOps: [],
markers: [],
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
regionStats: {},
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
@@ -149,6 +153,21 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
});
it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => {
expect(hasGeo()).toBe(true);
const ain = geoOf('ain-lqtt');
expect(ain).toBeDefined();
expect(ain!.area).toBeGreaterThanOrEqual(0); // km²
expect(ain!.population).toBeGreaterThanOrEqual(0);
// exact spot-check values pinned from the committed manifest (Ain, FRA)
expect(ain!.area).toBeCloseTo(22557.77, 1); // km²
expect(ain!.population).toBe(2304126);
});
it('(v0.28 #46) geoOf returns undefined for an unknown key', () => {
expect(geoOf('not-a-real-key-xyz')).toBeUndefined();
});
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
@@ -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
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
// and no region-geo: the per-region stats weighting degrades to uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
});
});
@@ -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
expect(hasCentroids()).toBe(false);
expect(centroidOf('ain-lqtt')).toBeUndefined();
// and no region-geo (not copied into this dist/) -> stats weighting is uniform
expect(hasGeo()).toBe(false);
expect(geoOf('ain-lqtt')).toBeUndefined();
});
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
@@ -233,6 +258,19 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod
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', () => {
const emptyRoot = path.join(base, 'empty');
mkdirSync(emptyRoot, { recursive: true });
+61 -3
View File
@@ -17,6 +17,8 @@
// field (v0.21, #16 P2)
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
// — the data behind LLM-placed map markers (v0.27, #44)
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
// — the weighting source for the per-region stats model (v0.28, #46)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
@@ -45,6 +47,8 @@ interface RegionData {
adjacency: Record<string, string[]>;
/** region key -> anchor point (from public/region-centroids.json). */
centroids: Record<string, { lat: number; lon: number; spread: number }>;
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
geo: Record<string, { area: number; population: number }>;
}
let data: RegionData | null = null;
@@ -72,6 +76,7 @@ export function loadRegions(root: string): boolean {
const geoFile = 'regions.geo.json';
const adjacencyFile = 'region-adjacency.json';
const centroidsFile = 'region-centroids.json';
const regionGeoFile = 'region-geo.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
@@ -85,13 +90,13 @@ export function loadRegions(root: string): boolean {
const geoPath = path.join(base, geoFile);
try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
const geoJson = JSON.parse(readFileSync(geoPath, 'utf8')) as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
const keyToInfo: Record<string, RegionInfo> = {};
const keysByCountry: Record<string, string[]> = {};
const validKeys = new Set<string>();
for (const f of geo.features ?? []) {
for (const f of geoJson.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
@@ -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;
} catch (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;
}
/**
* True when the region-geo manifest loaded with at least one entry (v0.28,
* #46). The server's jump route uses this to decide whether to hand the
* engine a geo resolver (which weights the per-region stats derivation by
* area×population) or fall back to a uniform (unweighted) median.
*/
export function hasGeo(): boolean {
return data !== null && Object.keys(data.geo).length > 0;
}
/**
* The precomputed anchor point for a region key: the centroid of its
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
@@ -252,6 +300,16 @@ export function centroidOf(key: string): { lat: number; lon: number; spread: num
return data?.centroids[key];
}
/**
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
* weighting source for the per-region stats model. `undefined` for an unknown
* key or when no geo data is loaded (the derivation then falls back to a
* uniform weight of 1 for that region).
*/
export function geoOf(key: string): { area: number; population: number } | undefined {
return data?.geo[key];
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for