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