diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts new file mode 100644 index 0000000..ec27bfe --- /dev/null +++ b/server/abilities/registry.test.ts @@ -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 { + 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 = { + '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 = { b2: 'ally', c3: 'rival' }; +const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }]; +const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }]; +const nationResources: Record = { + 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 = { 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 | 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 = { + // 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(); + } + }); +});