Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e2229ee2 | ||
|
|
38d3202472 | ||
|
|
58c56024bc | ||
|
|
3c187c6901 |
@@ -327,6 +327,29 @@ history/
|
|||||||
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
`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
|
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||||
when PR 2, the per-region LLM lever, ships).
|
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).**
|
- **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
|
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
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
|
|||||||
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
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`.
|
||||||
|
- **Engine (`server/engine.ts`).**
|
||||||
|
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||||
|
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||||
|
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||||
|
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||||
|
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||||
|
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||||
|
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||||
|
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||||
|
|
||||||
## v0.31.0 — 2026-10-15 10:22
|
## v0.31.0 — 2026-10-15 10:22
|
||||||
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||||
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.31.0",
|
"version": "0.33.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"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: {
|
regionStats: {
|
||||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
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 = [];
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||||
if (state.markers === undefined) state.markers = [];
|
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
|
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||||
// ONCE at load time (and never re-migrated afterwards — the field is
|
// 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
|
// then present): each owned region is seeded with a copy of its owner's
|
||||||
|
|||||||
+785
-21
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 {
|
import {
|
||||||
CONQUEST_MILITARY_MARGIN,
|
CONQUEST_MILITARY_MARGIN,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
@@ -14,12 +14,15 @@ import {
|
|||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
|
activeWarPairs,
|
||||||
applyNationOp,
|
applyNationOp,
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
|
atWar,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
confirmNationOp,
|
confirmNationOp,
|
||||||
freshNationId,
|
freshNationId,
|
||||||
|
freshWarId,
|
||||||
GET_NEIGHBOURS_TOOL,
|
GET_NEIGHBOURS_TOOL,
|
||||||
isMeaningful,
|
isMeaningful,
|
||||||
makeNeighboursExecutor,
|
makeNeighboursExecutor,
|
||||||
@@ -32,10 +35,13 @@ import {
|
|||||||
resolveNationRef,
|
resolveNationRef,
|
||||||
resolveRegionKey,
|
resolveRegionKey,
|
||||||
runTurn,
|
runTurn,
|
||||||
|
syncWarRelations,
|
||||||
validateMarkerOps,
|
validateMarkerOps,
|
||||||
validateNationOps,
|
validateNationOps,
|
||||||
validateRegionChanges,
|
validateRegionChanges,
|
||||||
|
validateTakeovers,
|
||||||
validateTurnResult,
|
validateTurnResult,
|
||||||
|
validateWarOps,
|
||||||
} from './engine';
|
} from './engine';
|
||||||
|
|
||||||
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
||||||
@@ -105,6 +111,8 @@ function baseState(): GameState {
|
|||||||
// (v0.28, #46) every owned region starts as a copy of its owner's stats,
|
// (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.
|
// so a fresh state's derived stats equal its nation-level stats.
|
||||||
regionStats: seedRegionStats(nations),
|
regionStats: seedRegionStats(nations),
|
||||||
|
// (v0.33.0, #56) no wars on a fresh state.
|
||||||
|
wars: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +550,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
});
|
});
|
||||||
expect(withOps).not.toBeNull();
|
expect(withOps).not.toBeNull();
|
||||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||||
@@ -564,7 +572,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], 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.nations.de.name).toBe('DE'); // still the old name
|
||||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
@@ -585,7 +593,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], 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([
|
expect(next.pendingNationOps).toEqual([
|
||||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||||
@@ -604,7 +612,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], 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' }]);
|
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||||
});
|
});
|
||||||
@@ -1061,7 +1069,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
applyTurnResult(state, result);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
expect(JSON.stringify(state)).toBe(snapshot);
|
||||||
@@ -1078,7 +1086,7 @@ describe('applyTurnResult', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result);
|
const next = applyTurnResult(state, result);
|
||||||
expect(next.pending).toEqual([]);
|
expect(next.pending).toEqual([]);
|
||||||
@@ -1101,7 +1109,7 @@ describe('applyTurnResult', () => {
|
|||||||
// is not a real key at all. Both are proposed as a gain by DE.
|
// 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: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
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
|
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||||
@@ -1127,7 +1135,7 @@ describe('applyTurnResult', () => {
|
|||||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], markers: { add: [], remove: [] },
|
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
@@ -1145,7 +1153,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
|||||||
regionChanges: [],
|
regionChanges: [],
|
||||||
relations: [],
|
relations: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], 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', () => {
|
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||||
@@ -1235,7 +1243,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [], 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', () => {
|
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||||
@@ -1350,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)', () => {
|
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||||
expect(
|
expect(
|
||||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
|
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1657,9 +1665,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
|||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
it('is true with an event, false when everything is empty', () => {
|
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: [], markers: { add: [], remove: [] } })).toBe(true);
|
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: [], 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: [], markers: { add: [], remove: [] } })).toBe(false);
|
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)', () => {
|
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||||
@@ -1672,7 +1680,7 @@ describe('isMeaningful', () => {
|
|||||||
relations: [],
|
relations: [],
|
||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1688,10 +1696,212 @@ describe('isMeaningful', () => {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('(v0.32.0 #54) a turn that only performs a takeover is meaningful (it survives the retry/nudge path)', () => {
|
||||||
|
expect(
|
||||||
|
isMeaningful({
|
||||||
|
date: 'x',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||||
|
it('a takeover renames the target nation; id, territory, relations, and stats are preserved', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
expect(next.nations.de.name).toBe('The Reich');
|
||||||
|
// id is preserved
|
||||||
|
expect(next.nations.de.id).toBe('de');
|
||||||
|
// territory is preserved
|
||||||
|
expect(next.nations.de.territory).toEqual(['DEU']);
|
||||||
|
// relations are preserved (keyed by id, not name)
|
||||||
|
expect(next.nations.de.relations).toEqual({});
|
||||||
|
// stats are preserved
|
||||||
|
expect(next.nations.de.stats.military).toBe(95);
|
||||||
|
// other nations are untouched
|
||||||
|
expect(next.nations.uk.name).toBe('UK');
|
||||||
|
expect(next.nations.us.name).toBe('US');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a takeover of the player\'s own nation preserves playerNationId (player keeps leading it)', () => {
|
||||||
|
const state = baseState(); // playerNationId is 'uk'
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
expect(next.playerNationId).toBe('uk'); // player still leads it
|
||||||
|
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||||
|
expect(next.nations.uk.id).toBe('uk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown nationRef → no-op (nation unchanged, no throw)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
// no nation was renamed
|
||||||
|
expect(next.nations.uk.name).toBe('UK');
|
||||||
|
expect(next.nations.us.name).toBe('US');
|
||||||
|
expect(next.nations.de.name).toBe('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ambiguous nationRef (two nations share the name) → no-op', () => {
|
||||||
|
const state = baseState();
|
||||||
|
// make two nations share the same name
|
||||||
|
state.nations.us.name = 'UK'; // now both 'uk' and 'us' are named 'UK'
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
// neither was renamed (ambiguous)
|
||||||
|
expect(next.nations.uk.name).toBe('UK');
|
||||||
|
expect(next.nations.us.name).toBe('UK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty/whitespace newRegimeName → no-op', () => {
|
||||||
|
const state = baseState();
|
||||||
|
// validateTakeovers drops entries with empty newRegimeName, so the validated
|
||||||
|
// result has no takeovers → applyTurnResult is a no-op.
|
||||||
|
const validated = validateTurnResult({
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
});
|
||||||
|
expect(validated!.takeovers).toEqual([]);
|
||||||
|
const next = applyTurnResult(state, validated!);
|
||||||
|
expect(next.nations.de.name).toBe('DE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple takeovers in one turn all apply', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [
|
||||||
|
{ nationRef: 'uk', newRegimeName: 'The Commonwealth' },
|
||||||
|
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||||||
|
],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||||
|
expect(next.nations.de.name).toBe('The Reich');
|
||||||
|
expect(next.nations.us.name).toBe('US'); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a takeover turn bumps the version once (same as any other turn)', () => {
|
||||||
|
const state = baseState(); // version 1
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result);
|
||||||
|
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a takeover via case-insensitive name resolution works', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
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);
|
||||||
|
expect(next.nations.de.name).toBe('The Reich');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('markers (point placement, v0.27 #44)', () => {
|
describe('markers (point placement, v0.27 #44)', () => {
|
||||||
@@ -1716,6 +1926,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||||
};
|
};
|
||||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||||
@@ -1740,13 +1952,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||||
const first = applyTurnResult(state, {
|
const first = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(first.markers).toHaveLength(1);
|
expect(first.markers).toHaveLength(1);
|
||||||
// a second turn proposes the exact same marker again
|
// a second turn proposes the exact same marker again
|
||||||
const second = applyTurnResult(first, {
|
const second = applyTurnResult(first, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(second.markers).toHaveLength(1);
|
expect(second.markers).toHaveLength(1);
|
||||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||||
@@ -1768,6 +1980,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||||
@@ -1810,6 +2024,8 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: {
|
markers: {
|
||||||
add: [
|
add: [
|
||||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||||
@@ -1835,7 +2051,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
];
|
];
|
||||||
const next = applyTurnResult(state, {
|
const next = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(next.markers).toHaveLength(1);
|
expect(next.markers).toHaveLength(1);
|
||||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||||
@@ -1851,14 +2067,14 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
// 1) uk releases the region → the marker becomes ownerless
|
// 1) uk releases the region → the marker becomes ownerless
|
||||||
const released = applyTurnResult(state, {
|
const released = applyTurnResult(state, {
|
||||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||||
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
|
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(released.markers[0].ownerNationId).toBeNull();
|
expect(released.markers[0].ownerNationId).toBeNull();
|
||||||
// 2) de re-claims the region → the marker's owner follows to de
|
// 2) de re-claims the region → the marker's owner follows to de
|
||||||
const reclaimed = applyTurnResult(released, {
|
const reclaimed = applyTurnResult(released, {
|
||||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
||||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||||
unowned: [], nationOps: [], markers: { add: [], remove: [] },
|
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
|
||||||
}, undefined, centroidOf);
|
}, undefined, centroidOf);
|
||||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||||
});
|
});
|
||||||
@@ -1924,6 +2140,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
|||||||
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
|
territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
|
||||||
});
|
});
|
||||||
const { state } = await runTurn({
|
const { state } = await runTurn({
|
||||||
@@ -1993,6 +2210,7 @@ function mkTurnState(
|
|||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
markers: [],
|
||||||
regionStats,
|
regionStats,
|
||||||
|
wars: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2006,6 +2224,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
|||||||
territoryChanges: partial.territoryChanges ?? [],
|
territoryChanges: partial.territoryChanges ?? [],
|
||||||
unowned: partial.unowned ?? [],
|
unowned: partial.unowned ?? [],
|
||||||
nationOps: partial.nationOps ?? [],
|
nationOps: partial.nationOps ?? [],
|
||||||
|
takeovers: partial.takeovers ?? [],
|
||||||
|
wars: partial.wars ?? { declare: [], end: [] },
|
||||||
markers: partial.markers ?? { add: [], remove: [] },
|
markers: partial.markers ?? { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2670,3 +2890,547 @@ describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', ()
|
|||||||
expect(prompt).not.toMatch(/takeoverRisks/);
|
expect(prompt).not.toMatch(/takeoverRisks/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt — takeovers contract (v0.32.0 #54)', () => {
|
||||||
|
it('both modes document the takeovers field and the automatic (NOT player-confirmed) rule', () => {
|
||||||
|
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||||
|
// the JSON shape carries the field in both modes
|
||||||
|
expect(p).toMatch(
|
||||||
|
/"takeovers": \[\{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"\}\]/,
|
||||||
|
);
|
||||||
|
// the rule: a takeover is an automatic in-world regime change, not a proposal
|
||||||
|
expect(p).toMatch(/Takeover \(automatic, NOT player-confirmed\)/);
|
||||||
|
// and it is tied to the stability + gathering mechanic
|
||||||
|
expect(p).toMatch(/low-stability nation/);
|
||||||
|
expect(p).toMatch(/gathering/);
|
||||||
|
// and it is distinguished from the player-confirmed nationOps rename
|
||||||
|
expect(p).toMatch(/use "nationOps" rename only for a diplomatic\/political name change/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||||
|
it('parses valid entries (trimmed, capped) and drops invalid ones', () => {
|
||||||
|
const takeovers = validateTakeovers([
|
||||||
|
{ nationRef: ' de ', newRegimeName: 'The Reich' },
|
||||||
|
{ nationRef: 'uk', newRegimeName: ' ' }, // empty name -> dropped
|
||||||
|
{ nationRef: '', newRegimeName: 'X' }, // empty ref -> dropped
|
||||||
|
{ newRegimeName: 'no ref' }, // missing nationRef -> dropped
|
||||||
|
{ nationRef: 'us' }, // missing newRegimeName -> dropped
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
42,
|
||||||
|
]);
|
||||||
|
expect(takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps both fields at 200 characters', () => {
|
||||||
|
const long = 'x'.repeat(250);
|
||||||
|
const takeovers = validateTakeovers([{ nationRef: long, newRegimeName: long }]);
|
||||||
|
expect(takeovers).toEqual([{ nationRef: 'x'.repeat(200), newRegimeName: 'x'.repeat(200) }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for non-array input and is never thrown by', () => {
|
||||||
|
expect(validateTakeovers(undefined)).toEqual([]);
|
||||||
|
expect(validateTakeovers('nope')).toEqual([]);
|
||||||
|
expect(validateTakeovers({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateTurnResult carries validated takeovers through (and defaults to [] when absent)', () => {
|
||||||
|
const withTakeovers = validateTurnResult({
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
regionChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [],
|
||||||
|
unowned: [],
|
||||||
|
nationOps: [],
|
||||||
|
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
|
markers: { add: [], remove: [] },
|
||||||
|
});
|
||||||
|
expect(withTakeovers).not.toBeNull();
|
||||||
|
// the valid one survives, the empty-name one is dropped
|
||||||
|
expect(withTakeovers!.takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||||
|
|
||||||
|
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||||
|
expect(without).not.toBeNull();
|
||||||
|
expect(without!.takeovers).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stub — takeovers (v0.32.0 #54)', () => {
|
||||||
|
it('the deterministic stub returns takeovers: [] (it never performs a takeover)', () => {
|
||||||
|
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.takeovers).toEqual([]);
|
||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+309
-3
@@ -15,6 +15,7 @@ import type {
|
|||||||
RegionStats,
|
RegionStats,
|
||||||
RoundItem,
|
RoundItem,
|
||||||
StatKey,
|
StatKey,
|
||||||
|
War,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +34,12 @@ import {
|
|||||||
type MarkerSpec,
|
type MarkerSpec,
|
||||||
type NationOp,
|
type NationOp,
|
||||||
type RegionStatChange,
|
type RegionStatChange,
|
||||||
|
type Takeover,
|
||||||
type TurnEventSpec,
|
type TurnEventSpec,
|
||||||
type TurnResult,
|
type TurnResult,
|
||||||
|
type WarDeclareOp,
|
||||||
|
type WarEndOp,
|
||||||
|
type WarInfo,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
@@ -199,6 +204,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
' {"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>"]}',
|
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
@@ -216,6 +223,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
|||||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||||
'- 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.',
|
'- 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.',
|
'- 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.',
|
'- 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 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.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -260,6 +269,13 @@ export function buildUserPrompt(
|
|||||||
* Omitted entirely when undefined.
|
* Omitted entirely when undefined.
|
||||||
*/
|
*/
|
||||||
markers?: MarkerInfo[],
|
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 {
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
@@ -372,6 +388,7 @@ export function buildUserPrompt(
|
|||||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||||
if (markers !== undefined) payload.markers = markers;
|
if (markers !== undefined) payload.markers = markers;
|
||||||
|
if (wars !== undefined) payload.wars = wars;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +526,8 @@ export function validateTurnResult(
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [], remove: [] },
|
markers: { add: [], remove: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -599,6 +618,14 @@ export function validateTurnResult(
|
|||||||
// list for the player to confirm/reject.
|
// list for the player to confirm/reject.
|
||||||
result.nationOps = validateNationOps(o.nationOps);
|
result.nationOps = validateNationOps(o.nationOps);
|
||||||
|
|
||||||
|
// (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly
|
||||||
|
// 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
|
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||||
// removals by stable id. Applied by applyTurnResult after the territory
|
// removals by stable id. Applied by applyTurnResult after the territory
|
||||||
// moves (a new marker's owner is the region's post-turn owner).
|
// moves (a new marker's owner is the region's post-turn owner).
|
||||||
@@ -705,13 +732,161 @@ export function validateRegionChanges(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||||
|
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||||
|
* revolution that renames a nation to its new regime name). Defensive like
|
||||||
|
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||||
|
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||||
|
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||||
|
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||||
|
* here. Entries with either field missing or whitespace-only are dropped.
|
||||||
|
*/
|
||||||
|
export function validateTakeovers(raw: unknown): Takeover[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: Takeover[] = [];
|
||||||
|
for (const e of raw) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
const nationRef = str(o.nationRef, 200);
|
||||||
|
const newRegimeName = str(o.newRegimeName, 200);
|
||||||
|
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||||||
|
}
|
||||||
|
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
|
* 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
|
* 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
|
* 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
|
* 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 {
|
export function isMeaningful(result: TurnResult): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -721,6 +896,11 @@ export function isMeaningful(result: TurnResult): boolean {
|
|||||||
result.regionChanges.length > 0 ||
|
result.regionChanges.length > 0 ||
|
||||||
result.unowned.length > 0 ||
|
result.unowned.length > 0 ||
|
||||||
result.nationOps.length > 0 ||
|
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.
|
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||||
result.markers.add.length > 0 ||
|
result.markers.add.length > 0 ||
|
||||||
result.markers.remove.length > 0
|
result.markers.remove.length > 0
|
||||||
@@ -973,6 +1153,99 @@ export function applyTurnResult(
|
|||||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Takeovers (v0.32.0, #54): non-military regime changes (coups /
|
||||||
|
// revolutions) that rename a nation to its new regime. Applied AFTER the
|
||||||
|
// per-region recompute (the rename doesn't depend on territory or stats,
|
||||||
|
// so it is order-independent with respect to those steps) and BEFORE the
|
||||||
|
// markers block (a marker's owner is a nation id, not a name, so the
|
||||||
|
// rename does not affect the owner recompute).
|
||||||
|
//
|
||||||
|
// For each takeover:
|
||||||
|
// 1. Resolve the target nation via `resolveNationRef` (id first, then
|
||||||
|
// case-insensitive unique name). Skip (no-op) if `null` — an
|
||||||
|
// unknown or ambiguous ref is silently dropped, never thrown.
|
||||||
|
// 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
|
||||||
|
// the display name changes — the stable id, territory, relations
|
||||||
|
// (keyed by id), stats, and `playerNationId` (which references the
|
||||||
|
// id, not the name) are all preserved. The player keeps leading an
|
||||||
|
// overthrown nation under its new regime.
|
||||||
|
for (const t of result.takeovers) {
|
||||||
|
const id = resolveNationRef(next, t.nationRef);
|
||||||
|
if (id === null) continue; // unknown / ambiguous ref: no-op
|
||||||
|
const n = next.nations[id];
|
||||||
|
if (!n) continue; // defensive: id resolved but nation missing (corrupt state)
|
||||||
|
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) ---
|
// --- Map markers (v0.27, #44) ---
|
||||||
//
|
//
|
||||||
// Markers are anchored to a region key and are processed against the FINAL
|
// Markers are anchored to a region key and are processed against the FINAL
|
||||||
@@ -1158,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,
|
* The baseline stats a freshly created nation starts with (split parts,
|
||||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||||
@@ -1448,7 +1734,25 @@ export async function runTurn(opts: {
|
|||||||
regionKey: m.regionKey,
|
regionKey: m.regionKey,
|
||||||
owner: m.ownerNationId,
|
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 =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|
||||||
@@ -1500,6 +1804,8 @@ export async function runTurn(opts: {
|
|||||||
territoryChanges: [],
|
territoryChanges: [],
|
||||||
unowned: [],
|
unowned: [],
|
||||||
nationOps: [],
|
nationOps: [],
|
||||||
|
takeovers: [],
|
||||||
|
wars: { declare: [], end: [] },
|
||||||
markers: { add: [], remove: [] },
|
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
|
// 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.
|
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||||
regionStats: {},
|
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
|
// 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
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const state: GameState = {
|
|||||||
markers: [],
|
markers: [],
|
||||||
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||||
regionStats: {},
|
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.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function mkState(
|
|||||||
pendingNationOps: [],
|
pendingNationOps: [],
|
||||||
markers: [],
|
markers: [],
|
||||||
regionStats: {} as GameState['regionStats'],
|
regionStats: {} as GameState['regionStats'],
|
||||||
|
wars: [],
|
||||||
};
|
};
|
||||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
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: [],
|
markers: [],
|
||||||
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||||
regionStats: {},
|
regionStats: {},
|
||||||
|
wars: [],
|
||||||
};
|
};
|
||||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
expect(allTerritoryKeys(state).size).toBe(58);
|
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.
|
* 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 }>;
|
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 {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -77,6 +84,14 @@ function truncate(s: string, n: number): string {
|
|||||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||||
* untouched.
|
* untouched.
|
||||||
|
*
|
||||||
|
* (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 {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -143,6 +158,8 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
territoryChanges,
|
territoryChanges,
|
||||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
nationOps: [], // the stub never proposes nation ops (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)
|
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,50 @@ export interface TerritoryChange {
|
|||||||
lose: string[];
|
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
|
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||||
// proposes, player confirms" model) ------------------------------------------
|
// proposes, player confirms" model) ------------------------------------------
|
||||||
//
|
//
|
||||||
@@ -68,6 +112,25 @@ export interface TerritoryChange {
|
|||||||
* engine's `resolveNationRef`).
|
* engine's `resolveNationRef`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
|
||||||
|
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
|
||||||
|
* (a player-confirmed diplomatic/political name change), a takeover is
|
||||||
|
* **automatic and in-world**: `applyTurnResult` applies it directly (no
|
||||||
|
* pending queue, no player confirm). Only `Nation.name` changes — the stable
|
||||||
|
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
|
||||||
|
* (which references the id) all stay intact, so the player keeps leading the
|
||||||
|
* nation under its new regime. `nationRef` is resolved exactly like the
|
||||||
|
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
|
||||||
|
* match — see `resolveNationRef`).
|
||||||
|
*/
|
||||||
|
export interface Takeover {
|
||||||
|
/** The target nation's id or current name (resolved by `resolveNationRef`). */
|
||||||
|
nationRef: string;
|
||||||
|
/** The name of the new regime to set as the nation's display name. */
|
||||||
|
newRegimeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||||
@@ -170,6 +233,19 @@ export interface MarkerInfo {
|
|||||||
owner: string | null;
|
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 /
|
* The per-type presentation of a marker: its display label (tooltip /
|
||||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||||
@@ -267,6 +343,30 @@ export interface TurnResult {
|
|||||||
* survivor. Empty on turns where the model proposes none.
|
* survivor. Empty on turns where the model proposes none.
|
||||||
*/
|
*/
|
||||||
nationOps: NationOp[];
|
nationOps: NationOp[];
|
||||||
|
/**
|
||||||
|
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
|
||||||
|
* effect **this turn** — each renames the target nation to `newRegimeName`.
|
||||||
|
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
|
||||||
|
* and in-world**: it is applied directly by `applyTurnResult` (like territory
|
||||||
|
* / state changes), not queued to `pendingNationOps`. Only the nation's
|
||||||
|
* `name` changes; the id, territory, relations, stats, and `playerNationId`
|
||||||
|
* are all preserved. Empty on turns where no takeover occurs. Required
|
||||||
|
* (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
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
* (the engine positions each at its region's centroid + a small
|
* (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
|
// 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.
|
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||||
regionStats: seedRegionStats(nations),
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,44 @@ export interface MapMarker {
|
|||||||
placedTurn: number;
|
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. */
|
/** A narrative event the simulation emitted on some turn. */
|
||||||
export interface EventEntry {
|
export interface EventEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -251,6 +289,20 @@ export interface GameState {
|
|||||||
* — additive, no data wipe.
|
* — additive, no data wipe.
|
||||||
*/
|
*/
|
||||||
regionStats: Record<string, RegionStats>;
|
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