Files
History/server/protocol.test.ts
T
kamilkrzywda 7866a1987d
CI / build + unit + e2e (pull_request) Successful in 2m33s
feat(#62): ideology / government-type change (v0.39.0)
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
2026-10-15 20:54:18 +00:00

369 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});