From 5cfbce861b2d45b7b2f4055779bb495d243722ab Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 16 Oct 2026 07:23:11 +0000 Subject: [PATCH] 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 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. --- server/abilities/shared.ts | 96 ++++++++++++++++++++ server/abilities/types.ts | 173 +++++++++++++++++++++++++++++++++++++ server/engine.ts | 99 +++------------------ 3 files changed, 279 insertions(+), 89 deletions(-) create mode 100644 server/abilities/shared.ts create mode 100644 server/abilities/types.ts diff --git a/server/abilities/shared.ts b/server/abilities/shared.ts new file mode 100644 index 0000000..7a2705f --- /dev/null +++ b/server/abilities/shared.ts @@ -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, + 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 { + 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 { + 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, +}; diff --git a/server/abilities/types.ts b/server/abilities/types.ts new file mode 100644 index 0000000..667b84d --- /dev/null +++ b/server/abilities/types.ts @@ -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; + /** 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; + readonly trades?: TradeAgreement[]; + readonly embargoes?: Embargo[]; + readonly nationResources?: Record; + 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 { + /** 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; +} diff --git a/server/engine.ts b/server/engine.ts index 560706a..9c5251b 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -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(EVENT_TYPES); const VALID_RELATIONS = new Set(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, - 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 { - 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 { - 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