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
CI / build + unit + e2e (pull_request) Successful in 2m34s
This commit is contained in:
+219
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user