refactor(#74) P4: wire engine/stub/db through the ABILITIES registry

Behavior-preserving rewire: every per-ability code path now iterates the
ordered ABILITIES registry instead of hand-enumerating the eight fields.

- engine.ts: buildSystemPrompt (schema lines + constraint bullets),
  buildUserPrompt (payload keys at their historical positions),
  validateTurnResult (per-field validate), applyTurnResult (ordered apply
  block), isMeaningful, and the runTurn "unclear" fallback all drive off
  ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted;
  the names the engine and its tests import are re-exported from
  ./abilities.
- abilities/types.ts: Ability.stub is a zero-arg method (the stub has no
  GameState/AbilityCtx to pass; it needs none) — all eight implementations
  were already arg-less.
- stub.ts: deterministicTurn fills the eight ability fields from the
  registry (each contributes its inert default), between the core fields
  and markers — JSON output stays byte-identical (verified against
  origin/master).
- db.ts: the legacy-row migration for ability-owned GameState fields
  (wars/trades/embargoes/industry) iterates the registry's stateFields,
  only-if-undefined, instead of a hardcoded field list.

No client changes, no new deps, no shared/ runtime deps. typecheck + lint
+ 511 unit tests green; stub JSON byte-identical to origin/master.

Fixes #74
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent 2d17c743f6
commit 15a1642eec
4 changed files with 129 additions and 1612 deletions
+29 -9
View File
@@ -4,6 +4,7 @@
import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RelationStatus, RoundItem } from '../shared/types';
import { ABILITIES, type AbilityField } from './abilities';
export const ADVISOR_MARKER = '<<ADVISOR>>';
@@ -229,7 +230,18 @@ export function deterministicTurn(p: StubPayload): TurnResult {
},
);
return {
// Core (non-ability) turn fields, in their historical position order.
const pre: Pick<
TurnResult,
| 'date'
| 'events'
| 'stateChanges'
| 'regionChanges'
| 'relations'
| 'territoryChanges'
| 'unowned'
| 'nationOps'
> = {
date: addDays(state?.date ?? '1939-09-01', 2),
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
@@ -238,16 +250,24 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
takeovers: [], // the stub never performs a takeover (see doc comment)
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment)
};
// The 8 ability fields, filled by the registry in its canonical order. The
// stub is ability-inert: every ability contributes its own no-op default
// (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology), which must stay byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();
// The single field positioned after the abilities (markers last, as before).
const post: Pick<TurnResult, 'markers'> = {
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
return { ...pre, ...abilities, ...post } as TurnResult;
}
const CANNED_ADVISOR: string[] = [