CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.
- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
= -5, TurnResult.ideology (required, between leaders and markers),
nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
re-derived -> political event), ideology block in applyTurnResult after
leaders / before markers, isMeaningful + validateTurnResult + system prompt
+ runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
canConquer,
|
||
CONQUEST_MILITARY_MARGIN,
|
||
deriveNationResources,
|
||
deriveNationStats,
|
||
IDEOLOGY_STABILITY_EFFECT,
|
||
migrateRegionStats,
|
||
nationForPrompt,
|
||
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: [],
|
||
trades: [],
|
||
embargoes: [],
|
||
industry: [],
|
||
};
|
||
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));
|
||
});
|
||
});
|
||
|
||
// --- deriveNationResources ---------------------------------------------------
|
||
|
||
describe('deriveNationResources (v0.36.0 #59)', () => {
|
||
const prof: Record<string, { oil: number; iron: number; food: number; industry: number }> = {
|
||
a: { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 },
|
||
b: { oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8 },
|
||
};
|
||
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 average', () => {
|
||
// a: weight 100×1000=100000, b: weight 1000×1000=1000000
|
||
// → (0.2×1e5 + 0.8×1e6)/1.1e6 = 82/110 = 0.7454… → 0.75 on every axis
|
||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => geo[k])).toEqual({
|
||
oil: 0.75, iron: 0.75, food: 0.75, industry: 0.75,
|
||
});
|
||
});
|
||
|
||
it('degrades to a uniform (unweighted) average without geo: equal weights', () => {
|
||
// equal weights 1,1 → the true mean 0.5 on every axis
|
||
expect(deriveNationResources(['a', 'b'], (k) => prof[k])).toEqual({
|
||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||
});
|
||
// same when geoOf is provided but returns undefined for the keys (missing geo)
|
||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], () => undefined)).toEqual({
|
||
oil: 0.5, iron: 0.5, food: 0.5, industry: 0.5,
|
||
});
|
||
});
|
||
|
||
it('skips a region key that has no resource-profile entry', () => {
|
||
// b has no profile → only a is counted → a's values
|
||
const out = deriveNationResources(['a', 'b'], (k) => (k === 'a' ? prof.a : undefined), (k) => geo[k]);
|
||
expect(out).toEqual(prof.a);
|
||
});
|
||
|
||
it('returns the zero profile when none of the owned regions has a profile', () => {
|
||
expect(deriveNationResources(['a', 'b'], () => undefined)).toEqual({
|
||
oil: 0, iron: 0, food: 0, industry: 0,
|
||
});
|
||
});
|
||
|
||
it('a single region yields exactly that region\'s profile', () => {
|
||
expect(deriveNationResources(['a'], (k) => prof[k], (k) => geo[k])).toEqual(prof.a);
|
||
});
|
||
|
||
it('treats zero/negative area or population as a fallback weight of 1', () => {
|
||
// a: area 0 → weight 1×1000=1000; b: full weight 1e6 → b dominates
|
||
const g: Record<string, { area: number; population: number }> = {
|
||
a: { area: 0, population: 1000 },
|
||
b: { area: 1000, population: 1000 },
|
||
};
|
||
expect(deriveNationResources(['a', 'b'], (k) => prof[k], (k) => g[k])).toEqual({
|
||
oil: 0.8, iron: 0.8, food: 0.8, industry: 0.8,
|
||
});
|
||
});
|
||
|
||
it('rounds each axis to 2 decimal places', () => {
|
||
// a=0.2 (w=1×1=1) + b=0.4 (w=2×1=2) → (0.2×1 + 0.4×2)/(1+2) = 1.0/3 = 0.3333… → 0.33
|
||
const g: Record<string, { area: number; population: number }> = {
|
||
a: { area: 1, population: 1 },
|
||
b: { area: 2, population: 1 },
|
||
};
|
||
const out = deriveNationResources(
|
||
['a', 'b'],
|
||
(k) => k === 'a'
|
||
? { oil: 0.2, iron: 0.2, food: 0.2, industry: 0.2 }
|
||
: { oil: 0.4, iron: 0.4, food: 0.4, industry: 0.4 },
|
||
(k) => g[k],
|
||
);
|
||
expect(out).toEqual({ oil: 0.33, iron: 0.33, food: 0.33, industry: 0.33 });
|
||
});
|
||
});
|
||
|
||
// --- 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
|
||
});
|
||
});
|
||
|
||
// --- IDEOLOGY_STABILITY_EFFECT (v0.39, #62) ----------------------------------
|
||
|
||
describe('IDEOLOGY_STABILITY_EFFECT (v0.39.0 #62)', () => {
|
||
it('a regime shift is a deterministic −5 stability dip', () => {
|
||
expect(IDEOLOGY_STABILITY_EFFECT).toBe(-5);
|
||
});
|
||
});
|
||
|
||
// --- nationForPrompt — ideology (v0.39, #62) ---------------------------------
|
||
|
||
describe('nationForPrompt — ideology (v0.39.0 #62)', () => {
|
||
it('stamps the ideology when a nation has one', () => {
|
||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||
n.ideology = 'fascism';
|
||
expect(nationForPrompt(n).ideology).toBe('fascism');
|
||
// and it survives JSON serialisation (the user prompt path)
|
||
expect(JSON.stringify(nationForPrompt(n))).toContain('"ideology":"fascism"');
|
||
});
|
||
|
||
it('omits the ideology from the JSON when a nation has none (undefined key dropped)', () => {
|
||
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
|
||
expect(n.ideology).toBeUndefined();
|
||
// JSON.stringify drops undefined-valued keys → no "ideology" in the prompt
|
||
expect(JSON.stringify(nationForPrompt(n))).not.toContain('ideology');
|
||
});
|
||
});
|