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.
157 lines
8.0 KiB
TypeScript
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 } : {}),
|
|
};
|