Files
History/server/abilities/wars.ts
T
kamilkrzywda 2d17c743f6 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.
2026-10-16 07:23:11 +00:00

157 lines
8.0 KiB
TypeScript

// 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 } : {}),
};