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:
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user