Files
History/server/abilities/registry.test.ts
T
kamilkrzywda 5477336537
CI / build + unit + e2e (pull_request) Successful in 2m10s
feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
2026-10-16 15:21:45 +00:00

471 lines
15 KiB
TypeScript

// The ordered LLM-ability registry: contract + golden-prompt tests (v0.40.0, #67).
//
// These tests pin the refactor's two load-bearing guarantees:
//
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
// prompt the engine builds are BYTE-IDENTICAL to the committed golden
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
// system goldens were regenerated in v0.41.0 when the disasters ability
// was added). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here. The USER prompt golden is unchanged
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 12 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended
// and every uniform loop still runs).
//
// Regenerate the goldens after an INTENTIONAL prompt change with:
// REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts
// (the goldens are then rewritten from the current engine output; review the
// diff and commit). In a normal run (no env) the tests assert byte-equality.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
activeWarPairs,
applyDisasterEvent,
applyIndustryEffects,
applyMigrationOp,
atWar,
buildSystemPrompt,
buildUserPrompt,
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
freshNationId,
freshWarId,
HYBRID_STAT_EFFECT,
isDisasterSeverity,
isDisasterType,
isHybridOpType,
isIndustryType,
isMigrationKind,
isMigrationScale,
LARGE_INFLUX_STABILITY_DIP,
MIGRATION_DESTINATION_EFFECTS,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
shiftRelationTowardHostile,
shiftRelationTowardPositive,
syncWarRelations,
validateDisasterEvents,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateMigrationOps,
validateNuclearOps,
validateTakeovers,
validateWarOps,
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from '../engine';
import { createPresetStore } from '../presets';
import { ABILITIES } from './index';
import type { Ability, AbilityField } from './types';
import type {
Embargo,
GameState,
IndustryProject,
Nation,
NationStats,
RegionResources,
RelationStatus,
RoundItem,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
const here = path.dirname(fileURLToPath(import.meta.url));
const goldensDir = path.join(here, 'goldens');
const REGEN = process.env.REGEN_GOLDENS === '1';
// --- The preset the system-prompt goldens were captured with. ---
const root = path.resolve(here, '..', '..');
const store = createPresetStore(path.join(root, 'presets'));
const preset = store.get('wwii-1939');
if (!preset) throw new Error('wwii-1939 preset not found');
// --- The small, fully-controlled deterministic state that exercises EVERY
// --- optional branch of buildUserPrompt (re-embedded from the P1
// --- golden-capture so the committed goldens can be regenerated). ---
function nation(
id: string,
name: string,
stats: NationStats,
territory: string[],
extra: Partial<Nation> = {},
): Nation {
return { id, name, type: 'major', stats, territory, relations: {}, ...extra };
}
const s = (m: number, e: number, st: number, p: number): NationStats => ({
military: m,
economy: e,
stability: st,
population: p,
});
const a1 = nation('a1', 'Alpha', s(60, 55, 70, 80), ['a-r1', 'a-r2'], {
nuclear: true,
leader: { name: 'Alpha Leader' },
ideology: 'democracy',
relations: { b2: 'ally', c3: 'rival' },
});
const b2 = nation('b2', 'Bravo', s(75, 40, 20, 30), ['b-r1', 'b-r2'], {
ideology: 'fascism',
relations: { a1: 'ally' },
});
const c3 = nation('c3', 'Charlie', s(30, 35, 80, 20), ['c-r1'], {
relations: { a1: 'rival' },
});
const state: GameState = {
id: 'golden-0000-0000-0000-0000-000000000000',
presetId: 'wwii-1939',
presetTitle: preset.title,
playerNationId: 'a1',
turn: 4,
date: '1940-02-14',
nations: { a1, b2, c3 },
events: [],
pending: [],
version: 9,
unowned: ['x-unowned'],
pendingNationOps: [],
markers: [],
regionStats: {
'a-r1': s(60, 55, 70, 80),
'a-r2': s(58, 53, 72, 78),
'b-r1': s(75, 40, 20, 30),
'b-r2': s(70, 42, 22, 28),
'c-r1': s(30, 35, 80, 20),
},
wars: [],
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// --- buildUserPrompt arguments: every optional branch exercised. ---
const items: RoundItem[] = [
{ kind: 'action', text: 'Rearm the northern border' },
{ kind: 'diplomacy', to: 'b2', text: 'We can still talk terms, no?' },
];
const regionReference: Record<string, string> = {
'a-r1': 'Alpha North (US)',
'a-r2': 'Alpha South (US)',
'b-r1': 'Bravo Plain (RU)',
'b-r2': 'Bravo Coast (RU)',
'c-r1': 'Charlie Isle (FR)',
'x-unowned': 'X Released (UN)',
};
const frontier: string[] = ['b-r1']; // owned by b2 (a non-player) → frontierMilitary
const noMansLand: string[] = ['x-unowned'];
const markers: MarkerInfo[] = [
{ id: 'mk0001', type: 'gathering', name: 'Bravo Rally', regionKey: 'b-r1', owner: 'b2' },
{ id: 'mk0002', type: 'rocket', name: 'Charlie Silo', regionKey: 'c-r1', owner: 'c3' },
{ id: 'mk0003', type: 'capital', name: 'Alpha Cap', regionKey: 'a-r1', owner: 'a1' },
];
const wars: WarInfo[] = [
{ id: 'w1', sides: ['a1', 'b2'], aims: ['control the border'] },
{ id: 'w2', sides: ['b2', 'c3'] },
];
const relations: Record<string, RelationStatus> = { b2: 'ally', c3: 'rival' };
const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }];
const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }];
const nationResources: Record<string, RegionResources> = {
a1: { oil: 0.5, iron: 0.2, food: 0.3, industry: 0.8 },
b2: { oil: 0.1, iron: 0.9, food: 0.1, industry: 0.2 },
c3: { oil: 0.2, iron: 0.3, food: 0.7, industry: 0.4 },
};
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
const seaZones: SeaZoneClaim[] = [];
// (v0.44.0, #66) a non-empty contested sample so the golden's user payload
// includes the `contested` key (region key + party NAMES, resolved from the
// state's nations). `b-r1` is owned by b2; the parties are a1 + b2.
const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }];
/**
* Assert the current prompt output is byte-identical to the committed golden —
* or, under REGEN_GOLDENS=1, rewrite the golden from the current output (a
* manual regeneration step; the test then passes without asserting).
*/
function checkGolden(name: string, current: string): void {
const file = path.join(goldensDir, name);
if (REGEN) {
mkdirSync(goldensDir, { recursive: true });
writeFileSync(file, current, 'utf8');
return;
}
const expected = readFileSync(file, 'utf8');
expect(current, `golden ${name} mismatch`).toBe(expected);
}
/** The canonical (default / no-op) value of EVERY TurnResult field. */
function fullDefaultTurn(): TurnResult {
return {
date: '1939-09-01',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
industry: { projects: [] },
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
sea: { control: [] },
contested: { open: [], resolve: [] },
markers: { add: [], remove: [] },
};
}
/** A "meaningful" (non-default) value for each ability field. */
function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
switch (field) {
case 'takeovers':
return [{ nationRef: 'a1', newRegimeName: 'Alpha Regime' }];
case 'wars':
return { declare: [{ sides: ['a1', 'b2'] }], end: [] };
case 'hybrid':
return { ops: [{ type: 'spy', from: 'a1', target: 'b2' }] };
case 'economy':
return { trade: [{ a: 'a1', b: 'c3' }], embargo: [] };
case 'industry':
return { projects: [{ regionKey: 'a-r1', type: 'oil' }] };
case 'nuclear':
return { arm: ['a1'], strikes: [] };
case 'leaders':
return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] };
case 'ideology':
return { changes: [{ target: 'a1', to: 'democracy' }] };
case 'disasters':
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
case 'migration':
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
case 'sea':
return { control: [{ zone: 'pacific', by: 'a1' }] };
case 'contested':
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 12-ability order (takeovers → contested)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
'hybrid',
'economy',
'industry',
'nuclear',
'leaders',
'ideology',
'disasters',
'migration',
'sea',
'contested',
]);
});
it('lists 12 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(12);
expect(new Set(fields).size).toBe(12);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
it('system prompt (no tool) is byte-identical to the committed golden', () => {
checkGolden('system-no-tool.txt', buildSystemPrompt(preset, false));
});
it('system prompt (tool) is byte-identical to the committed golden', () => {
checkGolden('system-tool.txt', buildSystemPrompt(preset, true));
});
it('user prompt is byte-identical to the committed golden', () => {
checkGolden(
'user.txt',
buildUserPrompt(
state,
items,
regionReference,
frontier,
noMansLand,
markers,
wars,
relations,
trades,
embargoes,
nationResources,
industryProjects,
nuclear,
seaZones,
contested,
),
);
});
it("each ability's default() equals the canonical TurnResult default for its field", () => {
const all = fullDefaultTurn();
for (const a of ABILITIES) {
expect(a.default(), `default() for ${a.field}`).toEqual(all[a.field]);
}
});
it('default() is a FRESH value on every call (never a shared reference)', () => {
for (const a of ABILITIES) {
expect(a.default(), `${a.field} default should be a fresh object`).not.toBe(a.default());
}
});
it('default() is never meaningful; a populated value always is', () => {
for (const a of ABILITIES) {
expect(a.isMeaningful(a.default()), `${a.field} default should not be meaningful`).toBe(false);
expect(a.isMeaningful(meaningfulFor(a.field)), `${a.field} populated should be meaningful`).toBe(
true,
);
}
});
it('declares exactly the durable GameState fields the db migration must default', () => {
const owned: string[] = [];
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
expect([...owned].sort()).toEqual(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']);
});
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
// The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when a 12th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
field: string;
schemaLine(): string;
constraint(): string;
}
const dummy: Loopable = {
field: 'custom',
schemaLine: () => ' "custom": [],',
constraint: () => '- A custom ability.',
};
const extended: readonly (Ability<AbilityField> | Loopable)[] = [...ABILITIES, dummy];
expect(extended.map((a) => a.field)).toEqual([
...ABILITIES.map((a) => a.field),
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(13);
expect(extended.map((a) => a.constraint())).toHaveLength(13);
expect(extended[12].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
// Backward compatibility: server/index.ts and engine.test.ts import these
// from the engine; the re-export block must keep them all exported (a
// missing re-export would also fail typecheck — this pins it at runtime).
const reexported: Record<string, unknown> = {
// shared primitives (./abilities/shared)
freshNationId,
freshWarId,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
// ability modules (./abilities)
isHybridOpType,
isIndustryType,
activeWarPairs,
atWar,
syncWarRelations,
HYBRID_STAT_EFFECT,
shiftRelationTowardHostile,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateNuclearOps,
validateTakeovers,
validateWarOps,
applyIndustryEffects,
// disasters (v0.41.0, #63)
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
// migration (v0.42.0, #64)
validateMigrationOps,
applyMigrationOp,
shiftRelationTowardPositive,
isMigrationKind,
isMigrationScale,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
// sea (v0.43.0, #65)
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
}
});
});