CI / build + unit + e2e (pull_request) Successful in 2m32s
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests
v0.33.0
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
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<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'],
|
||
wars: [],
|
||
};
|
||
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);
|
||
});
|
||
});
|
||
|
||
// --- 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
|
||
});
|
||
});
|