refactor(#74) P3: add the 8 ability modules + ABILITIES registry
Each per-feature LLM ability is now a self-contained module in server/abilities/ with its own Ability<K> const (schema line, constraint bullet, validator, applier, isMeaningful, stub default, user-payload field). The ordered ABILITIES registry (index.ts) assembles them in the canonical order: takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology. All function bodies are moved VERBATIM from engine.ts. The engine is untouched in this commit — P4 will rewire it to use the registry.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// The WARS ability (v0.33.0, #56) as a modular LLM-ability (v0.40.0, #67).
|
||||
// Wars are named first-class conflicts (`GameState.wars`), not just a `war`
|
||||
// relation: the model declares a war (≥2 belligerents, optional aims) or ends
|
||||
// one by id (armistice / capitulation). Declaring drags in each belligerent's
|
||||
// mutual-defense allies; the derived `war` relation is synced from the ACTIVE
|
||||
// wars by `syncWarRelations` (a war-ended pair falls back to `hostile`). The
|
||||
// validator and the apply body are moved VERBATIM from the pre-refactor
|
||||
// engine.ts.
|
||||
|
||||
import type { GameState } from '../../shared/types';
|
||||
import type { TurnResult, WarDeclareOp, WarEndOp } from '../../shared/protocol';
|
||||
import { freshWarId, resolveNationRef } from './shared';
|
||||
import { syncWarRelations } from './apply-helpers';
|
||||
import type { Ability } from './types';
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
|
||||
* `{ declare, end }` shape. Defensive like the rest of the parser — never
|
||||
* throws. `declare`: each entry must be an object whose `sides` is an array
|
||||
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
|
||||
* yields at least TWO distinct sides — anything else (a one-sided or
|
||||
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
|
||||
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
|
||||
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
|
||||
* of exactly `armistice` or `capitulation` (anything else is dropped);
|
||||
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
|
||||
* input yields `{ declare: [], end: [] }`.
|
||||
*/
|
||||
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
|
||||
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.declare)) {
|
||||
for (const e of o.declare) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const d = e as Record<string, unknown>;
|
||||
const sides = (Array.isArray(d.sides) ? d.sides : [])
|
||||
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
|
||||
.filter((s) => s !== '')
|
||||
// dedupe case-insensitively? No — sides are nation ids: keep exact
|
||||
// strings, drop exact duplicates only (resolution is later).
|
||||
.filter((s, i, arr) => arr.indexOf(s) === i);
|
||||
if (sides.length < 2) continue; // a war needs at least two belligerents
|
||||
const aims = Array.isArray(d.aims)
|
||||
? (d.aims as unknown[])
|
||||
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
|
||||
.filter((a) => a !== '')
|
||||
.slice(0, 5)
|
||||
: undefined;
|
||||
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(o.end)) {
|
||||
for (const e of o.end) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const w = e as Record<string, unknown>;
|
||||
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
|
||||
if (!id) continue;
|
||||
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
|
||||
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
|
||||
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Apply the turn's wars ops to the (cloned) `next` state,
|
||||
* mutating it in place and never throwing. Two kinds of change, applied in
|
||||
* this order against `next`:
|
||||
*
|
||||
* (1) DECLARE: each validated declare is resolved to concrete nation ids
|
||||
* (`resolveNationRef`, so the LLM may use an id OR a unique name) and
|
||||
* becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
|
||||
* with the acting turn and the turn's new date. Mutually-defensive
|
||||
* allies of the belligerents are DRAGGED IN: any nation that is an
|
||||
* `ally` of a belligerent (per the current, post-relations relations)
|
||||
* and is not already a side joins the war's `sides`. A declare with
|
||||
* fewer than two DISTINCT resolved sides is dropped; a declare whose
|
||||
* sides are all already contained in an existing ACTIVE war is a
|
||||
* duplicate and is skipped (the existing war stands).
|
||||
* (2) END: each validated end targets a war BY ID and sets its status to
|
||||
* the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
|
||||
* affected — an unknown id or an already-ended war is a no-op.
|
||||
* (A war may be declared and ended in the SAME turn: declare runs
|
||||
* first, so the just-declared id is a valid end target.)
|
||||
* (3) RELATION SYNC: `syncWarRelations(prev, next)` derives the `war`
|
||||
* relation from the ACTIVE wars — the load-bearing step that keeps
|
||||
* the relation table honest with the war entities. `prev` is the
|
||||
* state this turn started from (ctx.previous).
|
||||
*
|
||||
* Nothing here throws: every unresolvable / invalid op is dropped.
|
||||
*/
|
||||
export function applyWars(
|
||||
next: GameState,
|
||||
data: TurnResult['wars'],
|
||||
prev: GameState,
|
||||
actingTurn: number,
|
||||
): void {
|
||||
const takenWarIds = new Set(next.wars.map((w) => w.id));
|
||||
// (1) DECLARE
|
||||
for (const d of data.declare) {
|
||||
const resolved: string[] = [];
|
||||
for (const ref of d.sides) {
|
||||
const id = resolveNationRef(next, ref);
|
||||
if (id !== null && !resolved.includes(id)) resolved.push(id);
|
||||
}
|
||||
if (resolved.length < 2) continue; // not enough distinct belligerents
|
||||
// Duplicate: an active war already contains all of these sides.
|
||||
const dup = next.wars.some(
|
||||
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
|
||||
);
|
||||
if (dup) continue;
|
||||
const sides = [...resolved];
|
||||
// Mutual-defense drag-in: each belligerent's allies join the war.
|
||||
for (const s of resolved) {
|
||||
const rels = next.nations[s]?.relations ?? {};
|
||||
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
|
||||
if (!sides.includes(ally)) sides.push(ally);
|
||||
}
|
||||
}
|
||||
const id = freshWarId(takenWarIds);
|
||||
takenWarIds.add(id);
|
||||
next.wars.push({
|
||||
id,
|
||||
sides,
|
||||
declaredTurn: actingTurn,
|
||||
declaredDate: next.date,
|
||||
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
// (2) END (by id; active only)
|
||||
for (const e of data.end) {
|
||||
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
|
||||
if (!w) continue; // unknown id / already ended: no-op
|
||||
w.status = e.outcome;
|
||||
}
|
||||
// (3) RELATION SYNC (prev = the state this turn started from)
|
||||
syncWarRelations(prev, next);
|
||||
}
|
||||
|
||||
export const wars: Ability<'wars'> = {
|
||||
field: 'wars',
|
||||
default: () => ({ declare: [], end: [] }),
|
||||
validate: (raw) => validateWarOps(raw),
|
||||
apply: (state, data, ctx) => applyWars(state, data, ctx.previous, ctx.actingTurn),
|
||||
schemaLine: () =>
|
||||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||
constraint: () =>
|
||||
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
||||
isMeaningful: (data) => data.declare.length > 0 || data.end.length > 0,
|
||||
stateFields: ['wars'],
|
||||
stub: () => ({ declare: [], end: [] }),
|
||||
payload: (ctx) => (ctx.wars !== undefined ? { wars: ctx.wars } : {}),
|
||||
};
|
||||
Reference in New Issue
Block a user