Files
History/server/protocol.test.ts
T
kamilkrzywda b237fe8dd4
CI / build + unit + e2e (pull_request) Successful in 2m11s
feat: Resources & Industry — per-region resource profiles + industry LLM lever (v0.36.0, #59)
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
  public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
  GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
  (area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
  (per-region SET, first-declared-type wins), applyIndustryEffects
  (INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
  0/2+ owner regions kept but no boost; re-derive after), system+user prompt
  contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
  in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
  protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0

Fixes #59
2026-10-15 18:23:05 +00:00

340 lines
13 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,
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: [],
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
});
});