refactor(#74) P2: extract shared helpers to server/abilities + define the Ability type surface

Move the 5 shared reference/id primitives (resolveNationRef, resolveRegionKey,
freshNationId, freshWarId, NEW_NATION_STATS) from engine.ts into the new
server/abilities/shared.ts (breaking the engine<->ability import cycle), and
add server/abilities/types.ts with the Ability<K> interface + AbilityCtx /
UserPromptCtx types that the 8 per-feature ability modules will implement.
engine.ts imports the helpers and re-exports them so existing callers
(server/index.ts, the unit tests) are untouched. Behavior is identical; all
511 unit tests, lint and typecheck stay green.
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent fc908aaea9
commit 5cfbce861b
3 changed files with 279 additions and 89 deletions
+96
View File
@@ -0,0 +1,96 @@
// Shared reference-resolution / id-generation primitives used across the
// modular LLM abilities (and re-exported by the core engine). Living here —
// and in `./types.ts` rather than in `engine.ts` — breaks the engine <->
// ability import cycle: the ability modules import from here, `engine.ts`
// imports + re-exports them for the existing callers (`server/index.ts`, the
// unit tests), and no ability imports the engine. Pure and dependency-free.
import type { GameState, NationStats } from '../../shared/types';
/**
* Resolve a nation reference (the `nationRef` of a NationOp, or the `target` /
* `from` / `sides` of an ability op — the LLM may write either the nation's
* stable `id` or its human-readable `name`) to a concrete nation id: an exact
* id match wins, then a unique case-insensitive name match. Returns `null` when
* the reference is empty, unknown, or ambiguous (two or more nations share that
* name) — every caller treats `null` as "no-op" and nothing here ever throws.
*/
export function resolveNationRef(state: GameState, ref: string): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (state.nations[r]) return r; // exact id first
const matches = Object.values(state.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
}
/**
* (v0.29, #46 PR 2) Resolve a **region** reference (a region op's `region` /
* `regionKey` — the LLM may write either the region's stable **key** or its
* human-readable **name**) to a concrete region key: an exact key match wins,
* then a unique case-insensitive name match (via `nameOf`). Returns `null` when
* the reference is empty, unknown, or ambiguous (two or more regions share that
* name) — every caller treats `null` as "drop this change" and nothing here
* ever throws. Mirrors {@link resolveNationRef}. `validKeys` is the set of all
* region keys and `nameOf` maps a key to its human name (both supplied by
* server/regions.ts).
*/
export function resolveRegionKey(
ref: string,
validKeys: ReadonlySet<string>,
nameOf: (key: string) => string | undefined,
): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (validKeys.has(r)) return r; // exact key first
const lower = r.toLowerCase();
let match: string | null = null;
for (const k of validKeys) {
const name = nameOf(k);
if (name && name.trim().toLowerCase() === lower) {
if (match !== null) return null; // ambiguous: two or more regions share the name
match = k;
}
}
return match;
}
/**
* Generate a fresh, unused nation id for a newly created nation (split parts,
* v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds each id it
* creates to `taken` as it goes, so ids stay unique within an op and are never
* reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* Generate a fresh, unused war id for a newly declared {@link War}
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id it
* creates to `taken` as it goes, so ids stay unique within a turn and are never
* reused from an ended war.
*/
export function freshWarId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `w${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT a
* copy of the parent's stats (a small rump state is not the parent's military
* power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
+173
View File
@@ -0,0 +1,173 @@
// The shared type surface for the modular LLM-ability system (v0.40.0, #67).
// Every ability is a plain object conforming to `Ability`, listed (in its
// canonical apply / prompt / payload order) by `ABILITIES` in ./index.ts. This
// file holds only the *types* the ability modules program against — the
// concrete implementations live in the sibling one-file-per-ability modules.
// No runtime deps here (type-only imports), so it stays pure like the rest of
// the `server/abilities` package.
import type {
Embargo,
GameState,
IndustryProject,
RegionResources,
RelationStatus,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 8 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* field each one owns. This is the closed universe the ordered `ABILITIES`
* registry (./index.ts) is built from: a turn's world changes are exactly the
* core engine effects (events / stats / territory / no-man's-land / nation-ops
* / markers) plus, in order, these eight abilities.
*/
export type AbilityField =
| 'takeovers'
| 'wars'
| 'hybrid'
| 'economy'
| 'industry'
| 'nuclear'
| 'leaders'
| 'ideology';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
*
* `previous` is the world state BEFORE this turn: reference resolution and
* sole-ownership checks must see the state before this turn's own mutations, so
* abilities read `previous` (not the state they are mutating) to resolve refs.
* `actingTurn` is the turn number stamped onto anything the ability creates or
* ends. The region-manifest resolvers are each optional and are absent when
* their manifest is not loaded — an ability must degrade gracefully when one is
* missing (never throw).
*/
export interface AbilityCtx {
/** The world state before this turn (a read-only snapshot). */
readonly previous: GameState;
/** The turn number stamped onto anything this ability creates / ends. */
actingTurn: number;
/** All valid region keys (public/region-keys.json); undefined if not loaded. */
readonly validRegionKeys?: ReadonlySet<string>;
/** Region key -> human name; undefined if the key manifest is not loaded. */
readonly regionNameOf?: (key: string) => string | undefined;
/** Region key -> {area (km²), population}; undefined if not loaded (v0.28 #46). */
readonly geoOf?: (key: string) => { area: number; population: number } | undefined;
/** Region key -> synthesized resource profile; undefined if not loaded (v0.36 #59). */
readonly resourceOf?: (key: string) => RegionResources | undefined;
/**
* Region key -> centroid anchor {lat, lon, spread}; undefined if not loaded
* (v0.27 #44).
*/
readonly centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
/**
* The `getNeighbours` tool executor (verify land borders). Available to an
* ability that needs it; none of the current eight use it (it drives the
* core territory/conquest logic), but the type is here so a future one can.
*/
readonly getNeighbours?: (regionKeys: string[]) => string[];
}
/**
* The inputs the user-prompt builder hands to an ability's `payload` method —
* the optional buildUserPrompt parameters (the core-only ones — `items`,
* `regionReference`, `frontier`, `noMansLand`, `nuclear` — are consumed by the
* engine, not by an ability). Each ability reports only the payload keys it
* owns here; the engine merges them into the payload in the historical
* buildUserPrompt order (pinned by the user-prompt golden fixture).
*/
export interface UserPromptCtx {
readonly state: GameState;
readonly markers?: MarkerInfo[];
readonly wars?: WarInfo[];
readonly relations?: Record<string, RelationStatus>;
readonly trades?: TradeAgreement[];
readonly embargoes?: Embargo[];
readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[];
}
/**
* A single LLM ability — a feature the model can exercise via one named
* `TurnResult` field. Each ability is self-contained: it validates the raw
* JSON for its field, documents itself in the system prompt (a JSON schema line
* and a HARD CONSTRAINTS bullet), contributes its keys to the user payload,
* reports whether a non-default value makes the turn meaningful, declares the
* `GameState` fields it owns (for db migrations), and applies its field to the
* world. `K` pins the field so every value-bearing method operates on the
* field's exact `TurnResult[K]` type (no `any`).
*/
export interface Ability<K extends AbilityField = AbilityField> {
/** The `TurnResult` field this ability owns (its stable, unique name). */
readonly field: K;
/**
* The default (empty / no-op) value of this field — a FRESH value on every
* call (never a shared mutable reference). Used by the "unclear" fallback and
* the deterministic stub.
*/
default(): TurnResult[K];
/**
* Parse and defensively validate the raw (unknown) JSON for this field into a
* clean `TurnResult[K]`. Never throws — a missing / malformed value yields
* `default()`. NOTE: the hybrid / leaders / ideology low-level validators
* return a bare inner list, so those abilities' `validate` wraps that list
* into the field's object shape (`{ ops }` / `{ changes }`).
*/
validate(raw: unknown): TurnResult[K];
/**
* Apply this field's (validated) value to the world, MUTATING `state` in
* place. Never throws — any unresolvable reference or missing manifest
* degrades to a no-op. Abilities run in the fixed order of `ABILITIES`.
*/
apply(state: GameState, data: TurnResult[K], ctx: AbilityCtx): void;
/**
* The JSON schema line for this field in the system prompt's JSON block
* (e.g. `' "wars": {"declare":[…],"end":[…]},'`). Inserted by the engine,
* in `ABILITIES` order, between the `nationOps` block and the `markers` line.
*/
schemaLine(): string;
/**
* The HARD CONSTRAINTS bullet documenting this ability (e.g.
* `'- Wars ("wars") are named conflicts, …'`). Inserted by the engine, in
* `ABILITIES` order, between the `nationOps` bullets and the
* "- Prefer 1-4 events" bullet.
*/
constraint(): string;
/**
* Whether a non-default value of this field makes the turn "meaningful"
* (not an empty round). Mirrors the historical per-field predicate exactly.
*/
isMeaningful(data: TurnResult[K]): boolean;
/**
* The `GameState` fields this ability owns and that must be defaulted on a
* legacy SQLite row (db.ts migration, only-if-undefined). `undefined` for
* abilities that add no durable state (takeovers, hybrid, nuclear, leaders,
* ideology).
*/
readonly stateFields?: readonly string[];
/**
* The deterministic stub's value for this field. Every ability returns
* `default()` (the stub never exercises an ability, so e2e is unaffected) —
* kept as a method so the stub stays a uniform registry loop.
*/
stub(state: GameState, ctx: AbilityCtx): TurnResult[K];
/**
* The keys this ability contributes to the user prompt payload, each included
* only when it should be emitted (matching buildUserPrompt's "omit when
* undefined" behavior). The engine merges the returned entries into the
* payload at each key's exact historical position (pinned by the user-prompt
* golden).
*/
payload(ctx: UserPromptCtx): Record<string, unknown>;
}
+10 -89
View File
@@ -12,7 +12,6 @@ import type {
IndustryType,
MapMarker,
Nation,
NationStats,
Preset,
RelationStatus,
RegionResources,
@@ -73,6 +72,16 @@ import {
type WireMessage,
} from './llm';
import { ADVISOR_MARKER } from './stub';
import {
freshNationId,
freshWarId,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
} from './abilities/shared';
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
// importing these shared primitives from the engine unchanged.
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
@@ -2750,94 +2759,6 @@ function validPendingOpIndex(state: GameState, index: number): boolean {
return Number.isInteger(index) && index >= 0 && index < len;
}
/**
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
* write either the nation's stable `id` or its human-readable `name`) to a
* concrete nation id: an exact id match wins, then a unique case-insensitive
* name match. Returns `null` when the reference is empty, unknown, or
* ambiguous (two or more nations share that name) — every caller treats
* `null` as "no-op" and nothing here ever throws.
*/
export function resolveNationRef(state: GameState, ref: string): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (state.nations[r]) return r; // exact id first
const matches = Object.values(state.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
}
/**
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
* `region` — the LLM may write either the region's stable **key** or its
* human-readable **name**) to a concrete region key: an exact key match wins,
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
* when the reference is empty, unknown, or ambiguous (two or more regions
* share that name) — every caller treats `null` as "drop this change" and
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
* the set of all region keys and `nameOf` maps a key to its human name (both
* supplied by server/regions.ts).
*/
export function resolveRegionKey(
ref: string,
validKeys: ReadonlySet<string>,
nameOf: (key: string) => string | undefined,
): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (validKeys.has(r)) return r; // exact key first
const lower = r.toLowerCase();
let match: string | null = null;
for (const k of validKeys) {
const name = nameOf(k);
if (name && name.trim().toLowerCase() === lower) {
if (match !== null) return null; // ambiguous: two or more regions share the name
match = k;
}
}
return match;
}
/**
* Generate a fresh, unused nation id for a newly created nation (split
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
* each id it creates to `taken` as it goes, so ids stay unique within an
* op and are never reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* Generate a fresh, unused war id for a newly declared {@link War}
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
* it creates to `taken` as it goes, so ids stay unique within a turn and are
* never reused from an ended war.
*/
export function freshWarId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `w${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
* a copy of the parent's stats (a small rump state is not the parent's
* military power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
/**
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
* the removed nations' ids from every remaining nation's `relations` map so