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
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user