From b5e2229ee2bae1d21fdc1f646b287e941152dbf1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 15 Oct 2026 15:47:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(#56):=20War=20as=20a=20first-class=20state?= =?UTF-8?q?=20=E2=80=94=20declare/end=20ops,=20mutual-defense=20drag-in,?= =?UTF-8?q?=20relation=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 23 ++ CHANGELOG.md | 21 ++ package.json | 2 +- server/db.test.ts | 2 + server/db.ts | 4 + server/engine.test.ts | 521 ++++++++++++++++++++++++++++++++++++++-- server/engine.ts | 254 +++++++++++++++++++- server/index.ts | 4 + server/map.test.ts | 2 + server/protocol.test.ts | 1 + server/regions.test.ts | 1 + server/stub.ts | 12 + shared/protocol.ts | 70 ++++++ shared/scenario.ts | 3 + shared/types.ts | 52 ++++ 15 files changed, 947 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ec53d5..0a8ca2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -327,6 +327,29 @@ history/ `server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`, `scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys when PR 2, the per-region LLM lever, ships). +- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).** + Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in + `GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as + `[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list + (≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`, + `declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an + optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` + (both required, default `[]`): a **declare** creates a new active `War` (sides resolved via + `resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved + distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets + its `status` to the outcome (`armistice` or `capitulation`). After declare/end, + `syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite + sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you + cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not + covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching + active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense + drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the + war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted + id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations + per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars + (as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or + ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the + path is unit-tested only (`server/engine.test.ts`). - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a5166..14dde72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to **History** are listed here, newest first. +## v0.33.0 — 2026-10-15 15:45 +- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally. + - **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes). + - **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload. + - **Engine (`server/engine.ts`).** + - `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400. + - `activeWarPairs(wars): Set` — NUL-joined sorted pair keys for active-only wars. + - `atWar(wars, a, b)` — convenience check. + - `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created). + - `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`). + - `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`. + - `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful. + - `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs). + - `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id. + - `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`. + - **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`). + - **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only. + - **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars. + - **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync). + - **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage. + ## v0.32.0 — 2026-10-15 10:56 - **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime. - **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`. diff --git a/package.json b/package.json index 0bb06b2..df91e4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.32.0", + "version": "0.33.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/db.test.ts b/server/db.test.ts index fcf4ecf..6be559a 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -31,6 +31,8 @@ function makeState(id: string): GameState { regionStats: { GBR: { military: 80, economy: 70, stability: 60, population: 50 }, }, + // (v0.33.0, #56) no wars. + wars: [], }; } diff --git a/server/db.ts b/server/db.ts index 27d716c..02c6cb0 100644 --- a/server/db.ts +++ b/server/db.ts @@ -49,6 +49,10 @@ export function openDb(filePath: string): Db { if (state.pendingNationOps === undefined) state.pendingNationOps = []; // (v0.27, #44) Same for map markers: a legacy game has none placed. if (state.markers === undefined) state.markers = []; + // (v0.33.0, #56) Same for named wars: a legacy game has none declared + // (any pre-v0.33 `war` relation stays a bare/implicit relation — see + // syncWarRelations, which never force-creates a War entity). + if (state.wars === undefined) state.wars = []; // (v0.28, #46) Rows saved before per-region stats existed are migrated // ONCE at load time (and never re-migrated afterwards — the field is // then present): each owned region is seeded with a copy of its owner's diff --git a/server/engine.test.ts b/server/engine.test.ts index 24e73f6..13d44cd 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types'; +import type { GameState, MarkerType, Nation, NationStats, Preset, RelationStatus, RegionStats, RoundItem, War } from '../shared/types'; import { CONQUEST_MILITARY_MARGIN, makeEvent, @@ -14,12 +14,15 @@ import { import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import { deterministicTurn, makeStubLlmClient } from './stub'; import { + activeWarPairs, applyNationOp, applyTurnResult, + atWar, buildSystemPrompt, buildUserPrompt, confirmNationOp, freshNationId, + freshWarId, GET_NEIGHBOURS_TOOL, isMeaningful, makeNeighboursExecutor, @@ -32,11 +35,13 @@ import { resolveNationRef, resolveRegionKey, runTurn, + syncWarRelations, validateMarkerOps, validateNationOps, validateRegionChanges, validateTakeovers, validateTurnResult, + validateWarOps, } from './engine'; /** A fake LlmClient that returns a fixed list of raw responses in order. */ @@ -106,6 +111,8 @@ function baseState(): GameState { // (v0.28, #46) every owned region starts as a copy of its owner's stats, // so a fresh state's derived stats equal its nation-level stats. regionStats: seedRegionStats(nations), + // (v0.33.0, #56) no wars on a fresh state. + wars: [], }; } @@ -543,7 +550,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -565,7 +572,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -586,7 +593,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -605,7 +612,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1062,7 +1069,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1079,7 +1086,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1102,7 +1109,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1128,7 +1135,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1146,7 +1153,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1236,7 +1243,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1351,7 +1358,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1658,9 +1665,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1673,7 +1680,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1690,6 +1697,7 @@ describe('isMeaningful', () => { unowned: [], nationOps: [], takeovers: [], + wars: { declare: [], end: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); @@ -1707,6 +1715,7 @@ describe('isMeaningful', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -1726,6 +1735,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1755,6 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1775,6 +1786,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1798,6 +1810,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1820,6 +1833,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: ' ' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); @@ -1842,6 +1856,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { { nationRef: 'uk', newRegimeName: 'The Commonwealth' }, { nationRef: 'de', newRegimeName: 'The Reich' }, ], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1862,6 +1877,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1880,6 +1896,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1910,6 +1927,7 @@ describe('markers (point placement, v0.27 #44)', () => { unowned: [], nationOps: [], takeovers: [], + wars: { declare: [], end: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -1934,13 +1952,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -1963,6 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => { unowned: [], nationOps: [], takeovers: [], + wars: { declare: [], end: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -2006,6 +2025,7 @@ describe('markers (point placement, v0.27 #44)', () => { unowned: [], nationOps: [], takeovers: [], + wars: { declare: [], end: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -2031,7 +2051,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2047,14 +2067,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2120,6 +2140,7 @@ describe('markers (point placement, v0.27 #44)', () => { territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }], unowned: [], nationOps: [], + wars: { declare: [], end: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ @@ -2189,6 +2210,7 @@ function mkTurnState( pendingNationOps: [], markers: [], regionStats, + wars: [], }; } @@ -2203,6 +2225,7 @@ function mkTurnResult(partial: Partial): TurnResult { unowned: partial.unowned ?? [], nationOps: partial.nationOps ?? [], takeovers: partial.takeovers ?? [], + wars: partial.wars ?? { declare: [], end: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -2924,6 +2947,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { unowned: [], nationOps: [], takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }], + wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); @@ -2955,3 +2979,458 @@ describe('stub — takeovers (v0.32.0 #54)', () => { expect(result).toHaveProperty('takeovers'); }); }); + + +// ============================================================================ +// Wars — first-class state (v0.33.0, #56) +// ============================================================================ +// +// A `War` is a named entity in GameState.wars, not just a `war` relation. The +// engine (1) creates wars from a turn's `wars.declare` (fresh id, drag-in of +// mutual-defense allies), (2) closes them via `wars.end` (armistice / +// capitulation), and (3) DERIVES the `war` relation from the ACTIVE wars on +// every turn so the relation and the entities can never drift apart. A bare +// preset `war` relation with no war entity stays as-is (an implicit, unnamed +// war — never force-created, never downgraded). + +/** A test nation with the given relations (empty stats/territory). */ +const warN = (id: string, relations: Record = {}): Nation => ({ + id, + name: id.toUpperCase(), + type: 'major', + stats: NS(50, 50, 50, 50), + territory: [], + relations, +}); + +/** A GameState whose `wars` are set explicitly (mkTurnState defaults to []). */ +function warState(nations: Record, wars: War[] = []): GameState { + const s = mkTurnState(nations, {}, []); + s.wars = wars; + return s; +} + +describe('freshWarId (v0.33.0 #56)', () => { + it('returns the first free of w1, w2, ...', () => { + expect(freshWarId(new Set())).toBe('w1'); + expect(freshWarId(new Set(['w1']))).toBe('w2'); + expect(freshWarId(new Set(['w1', 'w2', 'w3']))).toBe('w4'); + }); +}); + +describe('validateWarOps (v0.33.0 #56)', () => { + it('non-object input yields empty declare/end', () => { + expect(validateWarOps(undefined)).toEqual({ declare: [], end: [] }); + expect(validateWarOps(null)).toEqual({ declare: [], end: [] }); + expect(validateWarOps('x')).toEqual({ declare: [], end: [] }); + expect(validateWarOps([])).toEqual({ declare: [], end: [] }); + }); + + it('keeps a declare with >=2 sides (trimmed, deduped) and optional aims', () => { + const out = validateWarOps({ + declare: [{ sides: [' uk ', 'de', ' uk '], aims: ['aim1', 'aim2'] }], + }); + expect(out.declare).toEqual([{ sides: ['uk', 'de'], aims: ['aim1', 'aim2'] }]); + }); + + it('drops a declare with fewer than 2 distinct sides', () => { + const out = validateWarOps({ declare: [{ sides: ['uk'] }, { sides: ['uk', 'uk'] }] }); + expect(out.declare).toEqual([]); + }); + + it('caps aims at 5 (each trimmed to 200 chars) and drops empty aims', () => { + const long = 'a'.repeat(300); + const out = validateWarOps({ + declare: [{ sides: ['uk', 'de'], aims: [long, '', 'b', 'c', 'd', 'e', 'f'] }], + }); + expect(out.declare).toEqual([ + { sides: ['uk', 'de'], aims: ['a'.repeat(200), 'b', 'c', 'd', 'e'] }, + ]); + }); + + it('keeps a valid end (id, outcome, optional terms) and drops invalid ones', () => { + const out = validateWarOps({ + end: [ + { id: 'w1', outcome: 'armistice', terms: 'peace' }, + { id: 'w2', outcome: 'capitulation' }, + { id: '', outcome: 'armistice' }, // empty id -> dropped + { id: 'w3', outcome: 'truce' }, // bad outcome -> dropped + { id: 'w4', outcome: 'armistice', terms: 'x'.repeat(500) }, // terms capped at 400 + ], + }); + expect(out.end).toEqual([ + { id: 'w1', outcome: 'armistice', terms: 'peace' }, + { id: 'w2', outcome: 'capitulation' }, + { id: 'w4', outcome: 'armistice', terms: 'x'.repeat(400) }, + ]); + }); +}); + +describe('validateTurnResult — wars (v0.33.0 #56)', () => { + it('defaults wars to empty when the input has none', () => { + const r = validateTurnResult({ date: 'x' }); + expect(r?.wars).toEqual({ declare: [], end: [] }); + }); + + it('carries through a validated declare and end', () => { + const r = validateTurnResult({ + date: 'x', + wars: { + declare: [{ sides: ['uk', 'de'] }], + end: [{ id: 'w1', outcome: 'capitulation' }], + }, + }); + expect(r?.wars.declare).toEqual([{ sides: ['uk', 'de'] }]); + expect(r?.wars.end).toEqual([{ id: 'w1', outcome: 'capitulation' }]); + }); +}); + +describe('activeWarPairs / atWar (v0.33.0 #56)', () => { + it('a 3-side active war yields all 3 pairs; non-active wars are excluded', () => { + const wars: War[] = [ + { id: 'w1', sides: ['a', 'b', 'c'], declaredTurn: 1, status: 'active' }, + { id: 'w2', sides: ['a', 'd'], declaredTurn: 1, status: 'armistice' }, + ]; + const pairs = activeWarPairs(wars); + expect(pairs.has('a\u0000b')).toBe(true); + expect(pairs.has('a\u0000c')).toBe(true); + expect(pairs.has('b\u0000c')).toBe(true); + expect(pairs.has('a\u0000d')).toBe(false); // w2 is not active + expect(atWar(wars, 'a', 'b')).toBe(true); + expect(atWar(wars, 'b', 'a')).toBe(true); // symmetric + expect(atWar(wars, 'a', 'd')).toBe(false); + }); + + it('an empty / all-ended set yields no pairs', () => { + expect(activeWarPairs([]).size).toBe(0); + expect( + activeWarPairs([{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'capitulation' }]).size, + ).toBe(0); + }); +}); + +describe('syncWarRelations (v0.33.0 #56)', () => { + it('sets both directions to war for pairs in an active war (overriding lesser relations)', () => { + const s = warState( + { a: warN('a', { b: 'neutral' }), b: warN('b', { a: 'neutral' }) }, + [{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }], + ); + syncWarRelations(s, s); + expect(s.nations.a.relations.b).toBe('war'); + expect(s.nations.b.relations.a).toBe('war'); + }); + + it('downgrades a pair to hostile when the war that tied them ends', () => { + const prev = warState( + { a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, + [{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }], + ); + const next = warState( + { a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, + [{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }], + ); + syncWarRelations(prev, next); + expect(next.nations.a.relations.b).toBe('hostile'); + expect(next.nations.b.relations.a).toBe('hostile'); + }); + + it('leaves a bare war (no active war behind it) untouched', () => { + const prev = warState({ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, []); + const next = warState({ a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, []); + syncWarRelations(prev, next); + expect(next.nations.a.relations.b).toBe('war'); + expect(next.nations.b.relations.a).toBe('war'); + }); + + it('keeps a pair at war when still covered by at least one active war', () => { + const prev = warState( + { a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, + [ + { id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }, + { id: 'w2', sides: ['a', 'b'], declaredTurn: 2, status: 'active' }, + ], + ); + const next = warState( + { a: warN('a', { b: 'war' }), b: warN('b', { a: 'war' }) }, + [ + { id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }, + { id: 'w2', sides: ['a', 'b'], declaredTurn: 2, status: 'active' }, + ], + ); + syncWarRelations(prev, next); + expect(next.nations.a.relations.b).toBe('war'); + expect(next.nations.b.relations.a).toBe('war'); + }); +}); + +describe('applyTurnResult — wars (v0.33.0 #56)', () => { + it('declare creates an active war with a fresh id, stamps turn/date/aims, and derives the war relation', () => { + const state = warState({ uk: warN('uk'), de: warN('de') }, []); + const result = mkTurnResult({ + wars: { declare: [{ sides: ['uk', 'de'], aims: ['conquest'] }], end: [] }, + }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(1); + const w = next.wars[0]; + expect(w.id).toBe('w1'); + expect([...w.sides].sort()).toEqual(['de', 'uk']); + expect(w.status).toBe('active'); + expect(w.declaredTurn).toBe(1); + expect(w.declaredDate).toBe(next.date); // stamped with the turn's new date + expect(w.aims).toEqual(['conquest']); + expect(next.nations.uk.relations.de).toBe('war'); + expect(next.nations.de.relations.uk).toBe('war'); + }); + + it('a second declare gets the next fresh id (w2)', () => { + const state = warState( + { a: warN('a'), b: warN('b'), c: warN('c') }, + [{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ wars: { declare: [{ sides: ['c', 'a'] }], end: [] } }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(2); + expect(next.wars[1].id).toBe('w2'); + }); + + it('end (armistice) sets the status and downgrades the belligerent pair to hostile', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] } }); + const next = applyTurnResult(state, result); + expect(next.wars[0].status).toBe('armistice'); + expect(next.nations.uk.relations.de).toBe('hostile'); + expect(next.nations.de.relations.uk).toBe('hostile'); + }); + + it('end (capitulation) sets the status (relations still fall back to hostile)', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'capitulation' }] } }); + const next = applyTurnResult(state, result); + expect(next.wars[0].status).toBe('capitulation'); + expect(next.nations.uk.relations.de).toBe('hostile'); + }); + + it('end with an unknown id is a no-op (the war stays active and at war)', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'nope', outcome: 'armistice' }] } }); + const next = applyTurnResult(state, result); + expect(next.wars[0].status).toBe('active'); + expect(next.nations.uk.relations.de).toBe('war'); + }); + + it('end of an already-ended war is a no-op (its status is not re-changed)', () => { + const state = warState( + { uk: warN('uk'), de: warN('de') }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'armistice' }], + ); + const result = mkTurnResult({ wars: { declare: [], end: [{ id: 'w1', outcome: 'capitulation' }] } }); + const next = applyTurnResult(state, result); + expect(next.wars[0].status).toBe('armistice'); + }); + + it('mutual-defense drag-in: an ally of a belligerent joins the war (and is now at war with its former ally)', () => { + const state = warState( + { + uk: warN('uk', { fr: 'ally' }), + fr: warN('fr', { uk: 'ally' }), + de: warN('de'), + }, + [], + ); + const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'de'] }], end: [] } }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(1); + expect([...next.wars[0].sides].sort()).toEqual(['de', 'fr', 'uk']); // fr dragged in + expect(next.nations.uk.relations.fr).toBe('war'); + expect(next.nations.fr.relations.uk).toBe('war'); + expect(next.nations.fr.relations.de).toBe('war'); + }); + + it('a duplicate declare (its sides are all in an existing active war) is skipped', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'de'] }], end: [] } }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(1); + expect(next.wars[0].id).toBe('w1'); + }); + + it('a declare whose sides do not all resolve is dropped (fewer than 2 belligerents)', () => { + const state = warState({ uk: warN('uk'), de: warN('de') }, []); + const result = mkTurnResult({ wars: { declare: [{ sides: ['uk', 'ghost'] }], end: [] } }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(0); + }); + + it('a bare preset war (no war entity) is preserved — never force-created, never downgraded', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [], + ); + const result = mkTurnResult({ wars: { declare: [], end: [] } }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(0); + expect(next.nations.uk.relations.de).toBe('war'); + expect(next.nations.de.relations.uk).toBe('war'); + }); + + it('a manual relations override on a warring pair is forced back to war while the war is active', () => { + const state = warState( + { uk: warN('uk', { de: 'war' }), de: warN('de', { uk: 'war' }) }, + [{ id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active' }], + ); + const result = mkTurnResult({ + relations: [{ a: 'uk', b: 'de', set: 'neutral' }], // the LLM tries to "make peace" + wars: { declare: [], end: [] }, + }); + const next = applyTurnResult(state, result); + expect(next.nations.uk.relations.de).toBe('war'); + expect(next.nations.de.relations.uk).toBe('war'); + }); + + it('a war declared and ended in the same turn ends up non-active (and the pair is not at war)', () => { + const state = warState( + { uk: warN('uk', { de: 'neutral' }), de: warN('de', { uk: 'neutral' }) }, + [], + ); + const result = mkTurnResult({ + wars: { + declare: [{ sides: ['uk', 'de'] }], + end: [{ id: 'w1', outcome: 'armistice' }], // w1 is the id assigned to the just-declared war + }, + }); + const next = applyTurnResult(state, result); + expect(next.wars).toHaveLength(1); + expect(next.wars[0].status).toBe('armistice'); + // never active across a turn boundary, so the pair keeps its prior relation + expect(next.nations.uk.relations.de).toBe('neutral'); + expect(atWar(next.wars, 'uk', 'de')).toBe(false); + }); +}); + +describe('isMeaningful — wars (v0.33.0 #56)', () => { + it('a declare-only turn is meaningful', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [], + wars: { declare: [{ sides: ['a', 'b'] }], end: [] }, + markers: { add: [], remove: [] }, + }), + ).toBe(true); + }); + + it('an end-only turn is meaningful', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [], + wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] }, + markers: { add: [], remove: [] }, + }), + ).toBe(true); + }); + + it('an empty-wars (and otherwise empty) turn is not meaningful', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + regionChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + takeovers: [], + wars: { declare: [], end: [] }, + markers: { add: [], remove: [] }, + }), + ).toBe(false); + }); +}); + +describe('buildSystemPrompt — wars contract (v0.33.0 #56)', () => { + it('documents the wars field and its constraints in both modes', () => { + for (const withTools of [false, true]) { + const p = buildSystemPrompt(preset, withTools); + expect(p).toContain('"wars": {"declare":'); + expect(p).toContain('"end":[{"id":""'); + expect(p).toContain('Wars ("wars") are named conflicts'); + expect(p).toContain('mutual-defense allies'); + expect(p).toContain('at most 1-2 declarations per turn'); + } + }); +}); + +describe('buildUserPrompt — wars (v0.33.0 #56)', () => { + it('carries the active wars (id, sides, aims) and omits ended ones', () => { + const state = warState( + { uk: warN('uk'), de: warN('de') }, + [ + { id: 'w1', sides: ['uk', 'de'], declaredTurn: 1, status: 'active', aims: ['conquest'] }, + { id: 'w2', sides: ['uk', 'de'], declaredTurn: 2, status: 'armistice' }, + ], + ); + // mirror runTurn: only active wars are surfaced, with aims when present + 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 out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], wars)); + expect(out.wars).toEqual([{ id: 'w1', sides: ['uk', 'de'], aims: ['conquest'] }]); + }); + + it('omits the wars field entirely when the param is undefined', () => { + const state = warState({ uk: warN('uk') }, []); + const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined)); + expect(out).not.toHaveProperty('wars'); + }); +}); + +describe('stub — wars (v0.33.0 #56)', () => { + it('the deterministic stub returns empty wars (it never declares or ends a war)', () => { + const payload = { + state: { + turn: 1, + date: '1939-09-01', + playerNationId: 'uk', + nations: { + uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] }, + de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] }, + }, + }, + pending: [{ kind: 'action' as const, text: 'test' }], + }; + const result = deterministicTurn(payload); + expect(result.wars).toEqual({ declare: [], end: [] }); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index b4fa78b..6b8d41f 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -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":["",""],"survivingRef":"","into":""}', ' ],', ' "takeovers": [{"nationRef":"","newRegimeName":""}],', + ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -219,6 +224,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Split: add {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}, ...]} 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":["",""],"survivingRef":"","into":""} 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":"","newRegimeName":""}]} 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":["",""],"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".', '- 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; + 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) 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 { + const pairs = new Set(); + 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 { } } +/** + * 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 { + 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: [] }, }; } diff --git a/server/index.ts b/server/index.ts index c755721..f9d47d6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -178,6 +178,10 @@ app.post('/api/games', (req: Request, res) => { // real region keys — each region starts as a copy of its owner's initial // stats, so the derived aggregate equals the nation's stats on turn 1. regionStats: {}, + // (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time + // (any preset-level `war` relation stays a bare/implicit one, not an + // entity — see syncWarRelations). + wars: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region diff --git a/server/map.test.ts b/server/map.test.ts index 18a9d86..48cfe41 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -52,6 +52,8 @@ const state: GameState = { markers: [], // (v0.28, #46) the map is ownership-only; the region-stats table is unused here. regionStats: {}, + // (v0.33.0, #56) the map is ownership-only; wars are unused here. + wars: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. diff --git a/server/protocol.test.ts b/server/protocol.test.ts index a29dde7..e9ba019 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -41,6 +41,7 @@ function mkState( pendingNationOps: [], markers: [], regionStats: {} as GameState['regionStats'], + wars: [], }; if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats']; else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row diff --git a/server/regions.test.ts b/server/regions.test.ts index d2d5d78..ef1bb33 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -110,6 +110,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { markers: [], // (v0.28, #46) unused here (allTerritoryKeys is ownership-only). regionStats: {}, + wars: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); diff --git a/server/stub.ts b/server/stub.ts index ec719b3..7926e6a 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -38,6 +38,13 @@ export interface StubPayload { * path is covered by unit tests against applyTurnResult, not by the stub. */ markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>; + /** + * (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by + * the engine's user prompt. The stub ignores this field — it never declares + * or ends a war (see deterministicTurn), so the war path is covered by unit + * tests against applyTurnResult, not by the stub. + */ + wars?: Array<{ id: string; sides: string[]; aims?: string[] }>; } function truncate(s: string, n: number): string { @@ -81,6 +88,10 @@ function truncate(s: string, n: number): string { * (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover * (it always returns `takeovers: []`) — the regime-rename path is covered by * unit tests only, keeping the e2e assertions untouched. + * + * (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always + * returns `wars: { declare: [], end: [] }`) — the war path is covered by unit + * tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -148,6 +159,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { unowned: [], // the stub never creates no man's land (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment) takeovers: [], // the stub never performs a takeover (see doc comment) + wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index dd37f72..d4ea295 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -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 diff --git a/shared/scenario.ts b/shared/scenario.ts index f249515..bab7b44 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -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; } diff --git a/shared/types.ts b/shared/types.ts index 17628c0..90daa66 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; + /** + * (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[]; } /**