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
+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);
});
});