// 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; if (Array.isArray(o.declare)) { for (const e of o.declare) { if (!e || typeof e !== 'object') continue; const d = e as Record; 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; 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":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', constraint: () => '- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["",""],"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":"","outcome":""}]} — 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 } : {}), };