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
+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[];
}
/**