Compare commits

...
Author SHA1 Message Date
kamilkrzywda b5e2229ee2 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
2026-10-15 15:47:06 +00:00
kamilkrzywda 38d3202472 Merge pull request 'feat(#54): non-military takeovers — automatic regime rename (v0.32.0)' (#55) from feat/54-takeover-consequence into master
CI / build + unit + e2e (push) Successful in 1m55s
2026-10-15 13:01:31 +02:00
15 changed files with 947 additions and 25 deletions
+23
View File
@@ -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
+21
View File
@@ -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<string>` — 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`.
+1 -1
View File
@@ -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).",
+2
View File
@@ -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: [],
};
}
+4
View File
@@ -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
+500 -21
View File
@@ -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>): 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<string, RelationStatus> = {}): 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<string, Nation>, 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":"<war 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: [] });
});
});
+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: [] },
};
}
+4
View File
@@ -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
+2
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -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);
+12
View File
@@ -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)
};
}
+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[];
}
/**