feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
CI / build + unit + e2e (pull_request) Successful in 2m32s

Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests

v0.33.0
This commit is contained in:
2026-10-15 15:47:06 +00:00
parent 38d3202472
commit b5e2229ee2
15 changed files with 947 additions and 25 deletions
+251 -3
View File
@@ -15,6 +15,7 @@ import type {
RegionStats,
RoundItem,
StatKey,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
@@ -36,6 +37,9 @@ import {
type Takeover,
type TurnEventSpec,
type TurnResult,
type WarDeclareOp,
type WarEndOp,
type WarInfo,
} from '../shared/protocol';
import {
DEFAULT_MAX_TOKENS,
@@ -201,6 +205,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
' ],',
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
@@ -219,6 +224,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
'- 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".',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -263,6 +269,13 @@ export function buildUserPrompt(
* Omitted entirely when undefined.
*/
markers?: MarkerInfo[],
/**
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
* stable id, sides and aims. Emitted verbatim (even when empty) so the
* model can reference a war's id in `TurnResult.wars.end` to end it.
* Omitted entirely when undefined.
*/
wars?: WarInfo[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -375,6 +388,7 @@ export function buildUserPrompt(
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
if (wars !== undefined) payload.wars = wars;
return JSON.stringify(payload, null, 2);
}
@@ -513,6 +527,7 @@ export function validateTurnResult(
unowned: [],
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
markers: { add: [], remove: [] },
};
@@ -607,6 +622,10 @@ export function validateTurnResult(
// by applyTurnResult (automatic, not player-confirmed).
result.takeovers = validateTakeovers(o.takeovers);
// (v0.33.0, #56) War declare/end ops: creating and ending named Wars.
// Applied directly by applyTurnResult (after takeovers, before markers).
result.wars = validateWarOps(o.wars);
// (v0.27, #44) Map-marker ops: new placements (validated here) and
// removals by stable id. Applied by applyTurnResult after the territory
// moves (a new marker's owner is the region's post-turn owner).
@@ -736,13 +755,138 @@ export function validateTakeovers(raw: unknown): Takeover[] {
return out;
}
/**
* (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) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
* {b,c} (any two distinct belligerents of the same active war are at war —
* see the War type's semantics). Pure; exported for the unit tests.
*/
export function activeWarPairs(wars: War[]): Set<string> {
const pairs = new Set<string>();
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
for (const w of wars) {
if (w.status !== 'active') continue;
for (let i = 0; i < w.sides.length; i++) {
for (let j = i + 1; j < w.sides.length; j++) {
pairs.add(key(w.sides[i], w.sides[j]));
}
}
}
return pairs;
}
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
export function atWar(wars: War[], a: string, b: string): boolean {
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
return activeWarPairs(wars).has(key);
}
/**
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
* applyTurnResult does) and never touching `prev`. For every unordered pair
* of distinct existing nations:
*
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
* of the pair's relation are set to `war` (the engine owns this relation
* while the war is active — a manual `relations` entry to a lesser status
* on a warring pair is overridden, so the two can never "make peace"
* without the war being ended);
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
* wars that tied them ended this turn) AND the current relation is
* `war` → both directions become `hostile` (a war is the strongest
* hostility — when it stops, the nations stay hostile, not suddenly
* neutral);
* - otherwise (not at war now, and the relation is not `war`, or the pair
* was never tied by an active war) → untouched: the LLM owns
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
* no active war ever covered (a preset leftover) stays as-is — an
* implicit, unnamed war, never force-created into a War entity and never
* clobbered by the sync.
*
* Never throws.
*/
export function syncWarRelations(prev: GameState, next: GameState): void {
const prevAtWar = activeWarPairs(prev.wars ?? []);
const nextAtWar = activeWarPairs(next.wars ?? []);
const ids = Object.keys(next.nations);
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = ids[i];
const b = ids[j];
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
const na = next.nations[a];
const nb = next.nations[b];
if (nextAtWar.has(key)) {
na.relations[b] = 'war';
nb.relations[a] = 'war';
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
na.relations[b] = 'hostile';
nb.relations[a] = 'hostile';
}
}
}
}
/**
* A turn is "meaningful" if it produced at least an event, a stat change, a
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
* a proposal is a real pending-world change the player will resolve, so it
* must survive the retry/nudge path instead of being replaced by the generic
* "unclear" result).
* "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended
* (v0.33.0 #56: both are real world changes).
*/
export function isMeaningful(result: TurnResult): boolean {
return (
@@ -754,6 +898,9 @@ export function isMeaningful(result: TurnResult): boolean {
result.nationOps.length > 0 ||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
result.takeovers.length > 0 ||
// (v0.33.0, #56) a war declared or ended is a real world change.
result.wars.declare.length > 0 ||
result.wars.end.length > 0 ||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
result.markers.add.length > 0 ||
result.markers.remove.length > 0
@@ -1030,6 +1177,75 @@ export function applyTurnResult(
n.name = t.newRegimeName;
}
// --- Wars (v0.33.0, #56: war as a first-class state) ---
//
// Wars are named entities (`GameState.wars`), not just a `war` relation.
// Two kinds of change, applied in this order against the (cloned) `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` derives the `war` relation from
// the ACTIVE wars (see its doc) — the load-bearing step that keeps
// the relation table honest with the war entities.
//
// Nothing here throws: every unresolvable / invalid op is dropped.
{
const takenWarIds = new Set(next.wars.map((w) => w.id));
// (1) DECLARE
for (const d of result.wars.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 result.wars.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(state, next);
}
// --- Map markers (v0.27, #44) ---
//
// Markers are anchored to a region key and are processed against the FINAL
@@ -1215,6 +1431,19 @@ export function freshNationId(taken: ReadonlySet<string>): string {
}
}
/**
* 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
@@ -1505,7 +1734,25 @@ export async function runTurn(opts: {
regionKey: m.regionKey,
owner: m.ownerNationId,
}));
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
// ended wars stay in GameState.wars as history but are not surfaced.
const wars = (state.wars ?? [])
.filter((w) => w.status === 'active')
.map((w) => ({
id: w.id,
sides: w.sides,
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
}));
const user = buildUserPrompt(
state,
items,
regionReference,
frontier,
state.unowned ?? [],
markers,
wars,
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -1558,6 +1805,7 @@ export async function runTurn(opts: {
unowned: [],
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
markers: { add: [], remove: [] },
};
}