Files
History/server/abilities/registry.test.ts
T
kamilkrzywda 4f8e3534ff refactor(#74) P5: add registry contract + golden-prompt tests
Adds server/abilities/registry.test.ts, the safety net for the refactor:

- GOLDEN PROMPTS: the refactored engine's system prompt (both tool
  variants) and user prompt are asserted BYTE-IDENTICAL to the committed
  fixtures in server/abilities/goldens/ (captured in P1 from the
  pre-refactor engine). The state builder the goldens were captured from
  is re-embedded here, with a REGEN_GOLDENS=1 mode to rewrite the
  fixtures after an intentional prompt change (verified a no-op — the
  refactored output is exactly stable).
- REGISTRY CONTRACT: ABILITIES is the canonical 8-ability order with 8
  distinct TurnResult fields; each ability's default() equals the
  canonical TurnResult default and is a FRESH value (never a shared
  reference); the default is never "meaningful" while a populated value
  is; the registry declares exactly the durable GameState fields the db
  migration must default (wars/trades/embargoes/industry); the registry
  composes (a hypothetical extra ability appends cleanly to every
  uniform loop); and the engine still re-exports every name its callers
  import from it (backward compatibility).

The throwaway _golden-capture.ts is deleted (its logic is now the
REGEN mode of this test).

Fixes #74
2026-10-16 07:23:11 +00:00

375 lines
12 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 refactored engine builds are BYTE-IDENTICAL to the committed
// golden fixtures in ./goldens/ (captured in P1 from the pre-refactor
// engine). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here.
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 8 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,
applyIndustryEffects,
atWar,
buildSystemPrompt,
buildUserPrompt,
freshNationId,
freshWarId,
HYBRID_STAT_EFFECT,
isHybridOpType,
isIndustryType,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
shiftRelationTowardHostile,
syncWarRelations,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateNuclearOps,
validateTakeovers,
validateWarOps,
} 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,
TradeAgreement,
} from '../../shared/types';
import type { 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: [],
};
// --- 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 };
/**
* 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: [] },
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' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 8-ability order (takeovers → ideology)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
'hybrid',
'economy',
'industry',
'nuclear',
'leaders',
'ideology',
]);
});
it('lists 8 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(8);
expect(new Set(fields).size).toBe(8);
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,
),
);
});
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(['embargoes', 'industry', '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 9th 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(9);
expect(extended.map((a) => a.constraint())).toHaveLength(9);
expect(extended[8].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,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
}
});
});