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
+70
View File
@@ -51,6 +51,50 @@ export interface TerritoryChange {
lose: string[];
}
// --- Wars (v0.33.0, #56): first-class war entities --------------------------
//
// A war is an explicit world entity (see types.War), not just a boolean
// relation: the LLM DECLARES one (it becomes an active War with an engine-
// assigned stable id) and later ENDS one by id. The engine derives the
// symmetric `war` relation from the set of ACTIVE wars on every turn (two
// nations are in a `war` relation iff they are both belligerents of at least
// one active war) and downgrades it to `hostile` when the war ends — so the
// relation can never drift apart from the war state.
/**
* (v0.33.0, #56) Declare a new war this turn. `sides` names every
* belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`,
* so a unique name also works); any two distinct sides end the turn in a
* `war` relation. `aims` are optional short war aims for the narrative. The
* engine assigns the stable id, the declared turn/date, drags in the
* mutual-defense allies of each belligerent, and drops the op when fewer than
* two sides resolve (never throws).
*/
export interface WarDeclareOp {
/** The belligerent nations (≥2 distinct ids or unique names). */
sides: string[];
/** Optional short war aims (narrative only). */
aims?: string[];
}
/**
* (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id
* (the model sees the current active wars in the user payload's `wars`
* field). `outcome` is `armistice` (a negotiated stop) or `capitulation`
* (one side surrenders); the war keeps its id/sides and stays in
* `GameState.wars` as history with that final status. `terms` are optional
* short peace terms (narrative only — the model narrates the details in
* `events`). An unknown id, or a war that is already ended, is a no-op
* (never throws).
*/
export interface WarEndOp {
/** The stable id of the active war to end (from the user payload). */
id: string;
outcome: 'armistice' | 'capitulation';
/** Optional short peace terms (narrative only). */
terms?: string;
}
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
// proposes, player confirms" model) ------------------------------------------
//
@@ -189,6 +233,19 @@ export interface MarkerInfo {
owner: string | null;
}
/**
* (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model
* (the `wars` field): its stable `id` (the model echoes it in
* `TurnResult.wars.end` to end it), its belligerent `sides`, and optional
* short `aims`. Ended wars are NOT included — they stay in `GameState.wars`
* as history, but the model only needs to address the active ones.
*/
export interface WarInfo {
id: string;
sides: string[];
aims?: string[];
}
/**
* The per-type presentation of a marker: its display label (tooltip /
* dossier), its color, and its glyph character (a symbol-layer text label —
@@ -297,6 +354,19 @@ export interface TurnResult {
* (default `[]`).
*/
takeovers: Takeover[];
/**
* (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars
* (≥2 distinct belligerents; the engine assigns the stable id / declared
* turn / date, drags in the mutual-defense allies of each belligerent, and
* derives the `war` relation between every pair of sides); `end` closes an
* existing ACTIVE war by id (`armistice` = a negotiated stop,
* `capitulation` = one side surrenders), which downgrades the relation the
* war had derived back to `hostile`. Both sub-fields are required and
* default `[]`; a turn with war activity is meaningful (it survives the
* retry/nudge path). The model sees the current active wars (id + sides +
* aims) in the user payload's `wars` field, so it can `end` by id.
*/
wars: { declare: WarDeclareOp[]; end: WarEndOp[] };
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
+3
View File
@@ -453,6 +453,9 @@ export function worldDraftToGameState(
// Seed AFTER the territories hold real region keys: each owned region gets
// a copy of its owner's stats, so the derived aggregate equals them.
regionStats: seedRegionStats(nations),
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
wars: [],
};
return state;
}
+52
View File
@@ -143,6 +143,44 @@ export interface MapMarker {
placedTurn: number;
}
/**
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
* between two or more nations. Unlike the boolean `war` relation (which a
* preset or a bare `relations` entry can carry without any structure behind
* it), a `War` is an explicit world entity the engine tracks: the LLM
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
* `GameState.wars` for the rest of the game as history after it ends.
*
* **Semantics of `sides`:** the flat list of every belligerent in the war
* (≥2 distinct nation ids). Any two distinct members of an active war's
* sides are at war with each other — the engine derives the symmetric `war`
* relation for every such pair on every turn (see the engine's
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
* ends. A mutual-defense ally of a belligerent is dragged into the same list
* on declaration (the engine does this automatically).
*/
export interface War {
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
id: string;
/** The belligerent nations (≥2 distinct ids; see the note above). */
sides: string[];
/** The turn number the war was declared on. */
declaredTurn: number;
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
declaredDate?: string;
/** Optional short war aims the LLM names at declaration. */
aims?: string[];
/**
* Lifecycle: `active` until a turn ends the war, then the end outcome —
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
*/
status: 'active' | 'armistice' | 'capitulation' | 'ended';
/** Optional region keys the war's front line runs through (reserved). */
front?: string[];
}
/** A narrative event the simulation emitted on some turn. */
export interface EventEntry {
id: string;
@@ -251,6 +289,20 @@ export interface GameState {
* — additive, no data wipe.
*/
regionStats: Record<string, RegionStats>;
/**
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
* wars on every turn (two nations are in a `war` relation iff they are both
* belligerents of ≥1 active war), so the boolean relation and the war
* entities can never drift apart. A bare preset `war` relation with no
* active war behind it stays as-is (an implicit, unnamed war — never
* force-created into an entity). Always `[]` on a fresh game; rows saved
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
* additive, no data wipe.
*/
wars: War[];
}
/**