Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e2229ee2 | ||
|
|
38d3202472 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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).",
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user