import { describe, expect, it } from 'vitest'; import { canConquer, CONQUEST_MILITARY_MARGIN, 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, regionStats?: Record, ): 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'], wars: [], trades: [], embargoes: [], }; 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 = { 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 = { 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); }); }); // --- canConquer (v0.30, #50: the military hard gate) ------------------------- describe('canConquer', () => { it('the default margin constant is 15', () => { expect(CONQUEST_MILITARY_MARGIN).toBe(15); }); it('an attacker within the margin of the defender proceeds (gap == margin)', () => { // attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below) expect(canConquer(10, 25)).toBe(true); }); it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => { // attacker 9, defender 25 → gap of 16 > margin → repel expect(canConquer(9, 25)).toBe(false); }); it('an equal attacker proceeds', () => { expect(canConquer(50, 50)).toBe(true); }); it('an attacker within the margin (gap < margin) proceeds', () => { expect(canConquer(30, 40)).toBe(true); // gap 10 expect(canConquer(14, 25)).toBe(true); // gap 11 }); it('an attacker ahead of the defender always proceeds', () => { expect(canConquer(90, 10)).toBe(true); }); it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => { expect(canConquer(50, 50, 0)).toBe(true); // equal expect(canConquer(51, 50, 0)).toBe(true); // ahead expect(canConquer(49, 50, 0)).toBe(false); // one below → repel }); });