From c77e81c45b9768d935efcc0d6dbbb4faa7f4e957 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 16 Oct 2026 12:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20#65=20sea=20control=20=E2=80=94=2011th?= =?UTF-8?q?=20ability=20(v0.43.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the sea ability: a fixed set of 12 named sea zones with sole-control semantics, contest-by-higher-military, and a deterministic per-turn +1 military/+1 economy bonus for each claimed zone. Durable (owns GameState.seaZones), additive migration, stub-inert (e2e unaffected). Fixes #65 Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented approved design is this richer 'sea' layer with a fixed zone set. --- AGENTS.md | 35 ++ CHANGELOG.md | 12 + package.json | 2 +- server/abilities/disasters.test.ts | 2 + server/abilities/goldens/system-no-tool.txt | 2 + server/abilities/goldens/system-tool.txt | 2 + server/abilities/goldens/user.txt | 3 +- server/abilities/index.ts | 21 +- server/abilities/migration.test.ts | 2 + server/abilities/registry.test.ts | 44 +- server/abilities/sea.test.ts | 494 ++++++++++++++++++++ server/abilities/sea.ts | 210 +++++++++ server/abilities/types.ts | 9 +- server/db.test.ts | 13 + server/engine.test.ts | 81 ++-- server/engine.ts | 28 +- server/index.ts | 2 + server/map.test.ts | 2 + server/protocol.test.ts | 2 + server/regions.test.ts | 2 + server/stub.ts | 24 +- shared/protocol.ts | 93 ++++ shared/scenario.ts | 2 + shared/types.ts | 31 ++ 24 files changed, 1060 insertions(+), 58 deletions(-) create mode 100644 server/abilities/sea.test.ts create mode 100644 server/abilities/sea.ts diff --git a/AGENTS.md b/AGENTS.md index fbbf424..aa34ec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -548,6 +548,41 @@ history/ re-claim of no-man's-land; the implemented, approved design is this richer four-kind nation-to-nation **stat** layer that deliberately never moves territory (a no-man's-land re-claim stays the `regionChanges` lever's job). +- **Sea control — sole control of a fixed set of 12 named sea zones with a deterministic per-turn bonus (v0.43.0, #65).** + `TurnResult.sea = SeaOps = { control: SeaControlOp[] }` (required, default `{ control: [] }`); + `SeaControlOp = { zone, by }` where `zone` is one of the 12 fixed sea-zone ids from + `SEA_ZONES` in `shared/protocol.ts` (`north-atlantic`, `south-atlantic`, `pacific`, `indian`, + `mediterranean`, `north-sea`, `baltic`, `arctic`, `black-sea`, `gulf-mexico`, `red-sea`, + `sargasso`) and `by` is a nation ref (id first, then a case-insensitive unique name, via the + shared `resolveNationRef`; unknown / ambiguous → dropped, never thrown). Like the wars and + industry layers this is **durable** (unlike the turn-transient hybrid / disasters / migration + layers): it owns the `GameState.seaZones` field (required, `[]` at creation; a legacy SQLite + row without the field loads as `[]` — via the generic `ABILITIES` `stateFields` loop in + `db.ts`, no `db.ts` code change needed). `applySeaControl` (run in the `ABILITIES` loop after + territory / wars / economy, before markers) upserts the turn's ops into `next.seaZones`: + per zone (in first-appearance order) a single surviving op sets or replaces the zone's + controller (replace in place — preserving position — or append); two or more surviving ops + on the same zone for different resolved nations make the zone **contested** — the higher + current `stats.military` wins (a tie goes to the earlier-listed op) — deterministic, no + hidden randomness. The layer **never touches `territory` / `unowned`** — it is fully parallel + to but independent of land. Every committed turn (including a turn with an empty `control`), + `applySeaZoneBonuses` grants each existing claim's controller `SEA_ZONE_BONUS = 1` military + + 1 economy (clamped to 0–100, mirrored to every region the nation owns, then the nation's + derived `stats` re-derived with the same 0-region / no-entry guards as the economy / migration + layers, so a 0-region nation keeps its directly-mutated value); a nation controlling several + zones stacks the bonus; a claim whose nation no longer exists (removed by a merge / split + this turn) is skipped — the claim is kept, never thrown. `isMeaningful` is + `control.length > 0`. The system prompt documents the field + constraints (at most 1–2 claims + per turn, each narrated in `events`); the user payload **always** carries the current + `seaZones` list (even when empty) — `payload()` → `{ seaZones: ctx.seaZones ?? [] }` — the + one ability whose `payload()` is not `{}` (the durable list, unlike the turn-transient + layers). The **stub never** proposes a sea op (`{ control: [] }`), so e2e (CI, `LLM_STUB=1`) + is unaffected and the path is unit-tested only (`server/abilities/sea.test.ts`). + **Design note:** the Gitea #65 body sketched a `naval` layer with a free-form `zones` + array; the implemented, approved design is this richer `sea` layer with a **fixed set of + 12 named zones**, sole-control semantics, contest-by-higher-military, and a durable + `seaZones` list — a cleaner, more deterministic model that the LLM can reason about without + inventing zone names each turn. - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b06945..7707aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to **History** are listed here, newest first. +## v0.43.0 — 2026-10-16 12:09 +- **#65: Sea control — a `TurnResult.sea` lever letting the LLM declare sole control over a fixed set of 12 named sea zones (North Atlantic, South Atlantic, Pacific, Indian, Mediterranean, North Sea, Baltic, Arctic, Black Sea, Gulf of Mexico, Red Sea, Sargasso), with a deterministic per-turn +1 military / +1 economy bonus for the controlling nation.** It is a **durable** feature (unlike the turn-transient `hybrid` / `disasters` / `migration` layers): `GameState.seaZones` persists across turns and in SQLite, and the user payload **always** carries the current `seaZones` list (even when empty) so the model knows who controls what. It lands as the **11th ability** of the v0.40.0 modular system, in a new `server/abilities/sea.ts` module, **registered last** in the `ABILITIES` registry (after `migration`). Sea control is fully **parallel to but independent of land**: it never touches `territory` / `unowned`, and moving land never touches a sea zone. + - **Types (`shared/protocol.ts`).** New `SEA_ZONES` (a frozen array of 12 `{ id, name }` pairs), `SEA_ZONE_IDS` (a `ReadonlySet` of all 12 ids), `isSeaZoneId(id)` (type guard), `seaZoneName(id)` (human-readable name; unknown → raw), `SeaControlOp` (`{ zone; by }`), and `SeaOps` (`{ control: SeaControlOp[] }`); `TurnResult.sea` is **required**, placed between `migration` and `markers`, default `{ control: [] }`. New `SeaZoneClaim` (`{ zone; by }`) in `shared/types.ts`; `GameState.seaZones: SeaZoneClaim[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed). + - **Ability module (`server/abilities/sea.ts`).** `field: 'sea'`; `default()` / `stub()` → a **fresh** `{ control: [] }` each call; `validate` wraps the exported `validateSeaOps` (defensive, never throws — accepts the `{ control: [...] }` object or a bare array; each op needs a valid `zone` (one of the 12 `SEA_ZONE_IDS`) and a non-empty `by` (trimmed, ≤200) — anything else is dropped); `apply` runs `applySeaControl` then `applySeaZoneBonuses`; `schemaLine()` / `constraint()` supply the prompt text; **`payload()` → `{ seaZones: ctx.seaZones ?? [] }` — the one ability that always emits a user-payload key** (the durable list, even when empty — unlike the turn-transient layers whose `payload()` is `{}`); `isMeaningful` = `control.length > 0`; **`stateFields: ['seaZones']`** (the durable `GameState` field, for the `db.ts` legacy-row migration). + - **Contest semantics (`applySeaControl`).** Each op's `by` is resolved via the shared `resolveNationRef` (id first, then a case-insensitive unique name; unknown / ambiguous → dropped, never thrown). Per zone (in first-appearance order): a single surviving op **sets or replaces** the zone's controller in `GameState.seaZones` (replace in place — preserving the entry's position — or append a new one); two or more surviving ops on the same zone for **different** resolved nations make the zone **contested**: the higher current `stats.military` wins (a tie goes to the op listed earlier in the `control` array) — deterministic, no hidden randomness. The layer **never touches `territory` / `unowned`**. + - **Per-turn bonus (`applySeaZoneBonuses`).** Every committed turn — **including a turn with an empty `control`** — each existing claim's controller earns `SEA_ZONE_BONUS = 1` military + 1 economy (clamped to 0–100, mirrored to every region the nation owns, then the nation's derived `stats` re-derived with the same 0-region / no-entry guards as the economy / migration layers, so a 0-region nation keeps its directly-mutated value). A nation controlling several zones **stacks** the bonus (one per zone). A claim whose nation no longer exists (removed by a merge / split this turn) is **skipped** — the claim is kept, never thrown. + - **Registry & prompt.** `sea` is added **last** in `ABILITIES` (existing order untouched); the system prompt gains the `sea` schema line and its constraint bullet ("at most 1–2 claims per turn, each narrated in `events`"); the **user prompt gains `"seaZones": []` as a new last key** (the intentional deviation from the turn-transient layers — the durable list is always emitted). The two committed system-prompt **goldens** are regenerated (each gains exactly the new `sea` schema line + constraint bullet); the **`user.txt` golden gains `"seaZones": []`** as the last key (verified by diff). + - **Tests.** New `server/abilities/sea.test.ts` (**~40 tests**): the zone-id predicate sets; `validateSeaOps` (shape / leniency / drops / field caps); `applySeaControl` (set / replace-in-place / append / contest by military / tie-break / same-nation dedup / name-resolve / unresolvable drop / ambiguous drop / territory-untouched); `applySeaZoneBonuses` (+1/+1 / clamping / stacking / 0-region direct-dip / stale-controller skip / empty no-op / territory-untouched); and the ability const contract (field, `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload()` incl. `?? []`, `validate` wrapping, `apply` ctx wiring). `registry.test.ts` is updated for the **11-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable fields, composes 12 / `extended[11]`, re-export pin, user-golden 14th arg). New `db.test.ts` legacy-row test (a pre-v0.43 save without `seaZones` loads with `[]`). + - **Design note (deviation from the issue body).** The Gitea #65 body sketched a `naval` layer with a free-form `zones` array; the approved design implemented here is the richer `sea` layer with a **fixed set of 12 named zones**, sole-control semantics, contest-by-higher-military, and a durable `seaZones` list — a cleaner, more deterministic model that the LLM can reason about without inventing zone names each turn. +- **Guarantees.** No client (`src/`) changes, no new runtime deps, `shared/` keeps zero runtime deps, no `@turf`/`@types/geojson` in any `.ts`. All unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is sea-inert — it never proposes a sea op). +- **Version** bumped `0.42.0 → 0.43.0`. + ## v0.42.0 — 2026-10-16 10:12 - **#64: Migration & refugees — a `TurnResult.migration` lever letting the LLM declare population flows between nations (ordinary migration / refugee flows / a spreading diaspora / an exodus), with a deterministic per-`scale` effect: the source loses a little population and economy, the destination gains a little (a `large` influx also strains its stability), and the from↔to relation is nudged one step toward the positive end of the ladder.** It is a **turn-transient** feature, exactly like the v0.34 `hybrid` and v0.41 `disasters` layers: **no new `GameState` field, no `db.ts` change, no durable list** — the ops are consumed inside `applyTurnResult` and leave nothing behind. It lands as the **10th ability** of the v0.40.0 modular system, in a new `server/abilities/migration.ts` module, **registered last** in the `ABILITIES` registry (after `disasters`). - **Types (`shared/protocol.ts`).** New `MigrationKind` (`'migration' | 'refugees' | 'diaspora' | 'exodus'`), `MigrationScale` (`'small' | 'moderate' | 'large'`), `MigrationOp` (`{ kind; from; to?; scale; detail? }` — `to` is required for every kind except `exodus`, which has no destination), and `MigrationOps` (`{ ops: MigrationOp[] }`); `TurnResult.migration` is **required**, placed between `disasters` and `markers`, default `{ ops: [] }`. diff --git a/package.json b/package.json index 0217510..f7fd2f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.42.0", + "version": "0.43.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/abilities/disasters.test.ts b/server/abilities/disasters.test.ts index 8067f3c..32cae23 100644 --- a/server/abilities/disasters.test.ts +++ b/server/abilities/disasters.test.ts @@ -70,6 +70,8 @@ function mkState( trades: [], embargoes: [], industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; } diff --git a/server/abilities/goldens/system-no-tool.txt b/server/abilities/goldens/system-no-tool.txt index 2da22a6..35d0144 100644 --- a/server/abilities/goldens/system-no-tool.txt +++ b/server/abilities/goldens/system-no-tool.txt @@ -41,6 +41,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen "ideology": {"changes":[{"target":"","to":""}]}, "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, + "sea": {"control":[{"zone":"","by":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -68,6 +69,7 @@ HARD CONSTRAINTS - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). - Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event). - Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event). +- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"","by":""}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/system-tool.txt b/server/abilities/goldens/system-tool.txt index dcb5a56..9e55d0b 100644 --- a/server/abilities/goldens/system-tool.txt +++ b/server/abilities/goldens/system-tool.txt @@ -41,6 +41,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that "ideology": {"changes":[{"target":"","to":""}]}, "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, + "sea": {"control":[{"zone":"","by":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -68,6 +69,7 @@ HARD CONSTRAINTS - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). - Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event). - Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event). +- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"","by":""}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/user.txt b/server/abilities/goldens/user.txt index 3f87413..a6fbed1 100644 --- a/server/abilities/goldens/user.txt +++ b/server/abilities/goldens/user.txt @@ -205,5 +205,6 @@ "regionKey": "a-r1", "type": "industry" } - ] + ], + "seaZones": [] } \ No newline at end of file diff --git a/server/abilities/index.ts b/server/abilities/index.ts index c913c12..f9efddd 100644 --- a/server/abilities/index.ts +++ b/server/abilities/index.ts @@ -1,8 +1,8 @@ // The ordered LLM-ability registry. // -// `ABILITIES` is the single source of truth for the 10 per-feature LLM abilities. +// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities. // The engine iterates it in this order: -// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration +// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea // for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability // validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt, // (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check, @@ -24,10 +24,11 @@ import { leaders } from './leaders'; import { ideology } from './ideology'; import { disasters } from './disasters'; import { migration } from './migration'; +import { sea } from './sea'; import type { Ability, AbilityField } from './types'; /** - * The 10 per-feature LLM abilities in their canonical apply order. + * The 11 per-feature LLM abilities in their canonical apply order. * The order matters: it determines the order of the schema lines in the * system prompt, the order of the validate calls, the order of the apply * steps, and the order of the payload fields in the user prompt. New @@ -45,6 +46,7 @@ export const ABILITIES: readonly Ability[] = [ ideology, disasters, migration, + sea, ]; // ── type re-exports ──────────────────────────────────────────────────────────── @@ -158,3 +160,16 @@ export { MIGRATION_DESTINATION_EFFECTS, LARGE_INFLUX_STABILITY_DIP, } from './migration'; + +// ── sea ────────────────────────────────────────────────────────────────────── + +export { + validateSeaOps, + applySeaControl, + applySeaZoneBonuses, + SEA_ZONE_BONUS, + SEA_ZONES, + SEA_ZONE_IDS, + isSeaZoneId, + seaZoneName, +} from './sea'; diff --git a/server/abilities/migration.test.ts b/server/abilities/migration.test.ts index fbdb0ec..3b3c589 100644 --- a/server/abilities/migration.test.ts +++ b/server/abilities/migration.test.ts @@ -82,6 +82,8 @@ function mkState( trades: [], embargoes: [], industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; } diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts index 228603b..5531a7e 100644 --- a/server/abilities/registry.test.ts +++ b/server/abilities/registry.test.ts @@ -12,7 +12,7 @@ // by the disasters feature (its payload is always empty). // // 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered -// list of the 10 abilities; each ability's default() equals the canonical +// list of the 11 abilities; each ability's default() equals the canonical // TurnResult default for its field, is a FRESH value (never a shared // reference), its default is not "meaningful" while a populated value is, // and the registry composes (a hypothetical extra ability can be appended @@ -68,6 +68,14 @@ import { validateNuclearOps, validateTakeovers, validateWarOps, + validateSeaOps, + applySeaControl, + applySeaZoneBonuses, + SEA_ZONE_BONUS, + SEA_ZONES, + SEA_ZONE_IDS, + isSeaZoneId, + seaZoneName, } from '../engine'; import { createPresetStore } from '../presets'; import { ABILITIES } from './index'; @@ -81,6 +89,7 @@ import type { RegionResources, RelationStatus, RoundItem, + SeaZoneClaim, TradeAgreement, } from '../../shared/types'; import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; @@ -154,6 +163,8 @@ const state: GameState = { trades: [], embargoes: [], industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; // --- buildUserPrompt arguments: every optional branch exercised. --- @@ -190,6 +201,7 @@ const nationResources: Record = { }; const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }]; const nuclear: Record = { a1: true, b2: false, c3: false }; +const seaZones: SeaZoneClaim[] = []; /** * Assert the current prompt output is byte-identical to the committed golden — @@ -228,6 +240,7 @@ function fullDefaultTurn(): TurnResult { ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, + sea: { control: [] }, markers: { add: [], remove: [] }, }; } @@ -255,11 +268,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] { return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] }; case 'migration': return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] }; + case 'sea': + return { control: [{ zone: 'pacific', by: 'a1' }] }; } } describe('ABILITIES registry (v0.40.0, #67)', () => { - it('is the canonical 10-ability order (takeovers → migration)', () => { + it('is the canonical 11-ability order (takeovers → sea)', () => { expect(ABILITIES.map((a) => a.field)).toEqual([ 'takeovers', 'wars', @@ -271,13 +286,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { 'ideology', 'disasters', 'migration', + 'sea', ]); }); - it('lists 10 distinct fields, each a key of TurnResult', () => { + it('lists 11 distinct fields, each a key of TurnResult', () => { const fields = ABILITIES.map((a) => a.field); - expect(fields).toHaveLength(10); - expect(new Set(fields).size).toBe(10); + expect(fields).toHaveLength(11); + expect(new Set(fields).size).toBe(11); const all = fullDefaultTurn(); for (const f of fields) expect(f in all).toBe(true); }); @@ -307,6 +323,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { nationResources, industryProjects, nuclear, + seaZones, ), ); }); @@ -336,7 +353,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { it('declares exactly the durable GameState fields the db migration must default', () => { const owned: string[] = []; for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields); - expect([...owned].sort()).toEqual(['embargoes', 'industry', 'trades', 'wars']); + expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']); }); it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => { @@ -361,9 +378,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { 'custom', ]); // every uniform loop runs over all N abilities, the appended one included: - expect(extended.map((a) => a.schemaLine())).toHaveLength(11); - expect(extended.map((a) => a.constraint())).toHaveLength(11); - expect(extended[10].schemaLine()).toBe(' "custom": [],'); + expect(extended.map((a) => a.schemaLine())).toHaveLength(12); + expect(extended.map((a) => a.constraint())).toHaveLength(12); + expect(extended[11].schemaLine()).toBe(' "custom": [],'); }); it('the engine still re-exports every name its callers import from it', () => { @@ -413,6 +430,15 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { MIGRATION_SOURCE_EFFECTS, MIGRATION_DESTINATION_EFFECTS, LARGE_INFLUX_STABILITY_DIP, + // sea (v0.43.0, #65) + validateSeaOps, + applySeaControl, + applySeaZoneBonuses, + SEA_ZONE_BONUS, + SEA_ZONES, + SEA_ZONE_IDS, + isSeaZoneId, + seaZoneName, }; for (const [name, value] of Object.entries(reexported)) { expect(value, `engine should re-export ${name}`).toBeDefined(); diff --git a/server/abilities/sea.test.ts b/server/abilities/sea.test.ts new file mode 100644 index 0000000..77dff36 --- /dev/null +++ b/server/abilities/sea.test.ts @@ -0,0 +1,494 @@ +// Unit tests for the SEA (naval / sea control) ability (v0.43.0, #65) — the +// modular, DURABLE LLM-ability that gives nations sole control over a fixed +// set of 12 named sea zones: the zone is upserted in `GameState.seaZones` +// (replace in place, contest by higher current military), and every committed +// turn the controller earns a deterministic +1 military / +1 economy bonus +// (clamped, mirrored to its regions, re-derived). The layer never touches +// `territory` / `unowned` — it is fully parallel to but independent of land. +// This is the ability's own test file (the registry's cross-cutting +// guarantees — order, distinct fields, the system/user goldens — are covered +// by ./registry.test.ts). The stub never proposes a sea op, so the apply path +// is exercised here, not by the e2e suite. + +import { describe, expect, it } from 'vitest'; + +import { + SEA_ZONES, + SEA_ZONE_IDS, + applySeaControl, + applySeaZoneBonuses, + isSeaZoneId, + sea, + seaZoneName, + validateSeaOps, +} from './sea'; + +import type { + GameState, + Nation, + NationStats, + RegionStats, + SeaZoneClaim, +} from '../../shared/types'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +/** Build a NationStats from the four canonical indices (military, economy, stability, population). */ +function NS(military: number, economy: number, stability: number, population: number): NationStats { + return { military, economy, stability, population }; +} + +/** Build a RegionStats from the four canonical indices. */ +function RS(military: number, economy: number, stability: number, population: number): RegionStats { + return { military, economy, stability, population }; +} + +/** A minimal nation: the given id/name/stats/region-key territory. */ +function mkNation( + id: string, + name: string, + stats: NationStats, + territory: string[], +): Nation { + return { id, name, type: 'major', stats, territory, relations: {} }; +} + +/** + * A minimal GameState carrying the given nations (keyed by id), per-region + * stats, and an optional existing sea-zone list. Every other required field + * is at its fresh-game baseline. + */ +function mkState( + nations: Record, + regionStats: Record, + seaZones: SeaZoneClaim[] = [], +): GameState { + return { + id: 'g1', + presetId: 'test', + presetTitle: 'Test', + playerNationId: Object.keys(nations)[0] ?? 'a1', + turn: 1, + date: '1939-09-01', + nations, + events: [], + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + regionStats, + wars: [], + trades: [], + embargoes: [], + industry: [], + seaZones, + }; +} + +// --------------------------------------------------------------------------- +// SEA_ZONES / isSeaZoneId / seaZoneName +// --------------------------------------------------------------------------- + +describe('SEA_ZONES / SEA_ZONE_IDS / isSeaZoneId / seaZoneName', () => { + it('exposes exactly the 12 fixed sea zones', () => { + expect(SEA_ZONES).toHaveLength(12); + const ids = SEA_ZONES.map((z) => z.id); + expect(ids).toEqual([ + 'north-atlantic', + 'south-atlantic', + 'pacific', + 'indian', + 'mediterranean', + 'north-sea', + 'baltic', + 'arctic', + 'black-sea', + 'gulf-mexico', + 'red-sea', + 'sargasso', + ]); + }); + + it('SEA_ZONE_IDS is a ReadonlySet of all 12 ids', () => { + expect(SEA_ZONE_IDS).toBeInstanceOf(Set); + expect(SEA_ZONE_IDS.size).toBe(12); + for (const z of SEA_ZONES) expect(SEA_ZONE_IDS.has(z.id)).toBe(true); + }); + + it('isSeaZoneId accepts each known id and rejects unknown / non-strings', () => { + for (const z of SEA_ZONES) expect(isSeaZoneId(z.id)).toBe(true); + expect(isSeaZoneId('atlantic-ocean')).toBe(false); + expect(isSeaZoneId('')).toBe(false); + }); + + it('seaZoneName returns the human-readable name; an unknown id is returned raw', () => { + expect(seaZoneName('pacific')).toBe('Pacific Ocean'); + expect(seaZoneName('mediterranean')).toBe('Mediterranean Sea'); + expect(seaZoneName('nope')).toBe('nope'); + }); +}); + +// --------------------------------------------------------------------------- +// validateSeaOps +// --------------------------------------------------------------------------- + +describe('validateSeaOps', () => { + it('accepts the { control: [...] } object shape and a bare array', () => { + const op = { zone: 'pacific', by: 'a1' }; + expect(validateSeaOps({ control: [op] })).toEqual({ control: [op] }); + expect(validateSeaOps([op])).toEqual({ control: [op] }); + }); + + it('drops an op with an unknown / missing zone', () => { + expect(validateSeaOps([{ zone: 'nope', by: 'a1' }])).toEqual({ control: [] }); + expect(validateSeaOps([{ by: 'a1' }])).toEqual({ control: [] }); + expect(validateSeaOps([{ zone: '', by: 'a1' }])).toEqual({ control: [] }); + }); + + it('drops an op with an empty / missing `by` (the controller)', () => { + expect(validateSeaOps([{ zone: 'pacific', by: '' }])).toEqual({ control: [] }); + expect(validateSeaOps([{ zone: 'pacific' }])).toEqual({ control: [] }); + }); + + it('drops a non-object entry', () => { + expect(validateSeaOps([42, 'nope', null, { zone: 'pacific', by: 'a1' }])).toEqual({ + control: [{ zone: 'pacific', by: 'a1' }], + }); + }); + + it('a missing / malformed top-level value yields { control: [] }', () => { + expect(validateSeaOps(undefined)).toEqual({ control: [] }); + expect(validateSeaOps(null)).toEqual({ control: [] }); + expect(validateSeaOps('nope')).toEqual({ control: [] }); + expect(validateSeaOps({ control: 'nope' })).toEqual({ control: [] }); + }); + + it('caps `zone` at 100 and `by` at 200 chars', () => { + const longBy = 'f'.repeat(500); + const out = validateSeaOps([{ zone: 'pacific', by: longBy }]); + expect(out.control).toHaveLength(1); + expect(out.control[0].by).toHaveLength(200); + }); +}); + +// --------------------------------------------------------------------------- +// applySeaControl +// --------------------------------------------------------------------------- + +describe('applySeaControl', () => { + it('a single op sets a new zone claim', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + ); + applySeaControl(state, [{ zone: 'pacific', by: 'a1' }]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + }); + + it('replaces an existing claim in place (position preserved)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) }, + [ + { zone: 'pacific', by: 'a1' }, + { zone: 'indian', by: 'b2' }, + ], + ); + // b2 now claims pacific — the pacific claim moves to b2 but stays at index 0 + applySeaControl(state, [{ zone: 'pacific', by: 'b2' }]); + expect(state.seaZones).toEqual([ + { zone: 'pacific', by: 'b2' }, + { zone: 'indian', by: 'b2' }, + ]); + }); + + it('appends a new zone after existing claims', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + applySeaControl(state, [{ zone: 'arctic', by: 'a1' }]); + expect(state.seaZones).toEqual([ + { zone: 'pacific', by: 'a1' }, + { zone: 'arctic', by: 'a1' }, + ]); + }); + + it('a contest: the higher current military wins', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) }, + ); + // b2 has higher military (90 > 80) → b2 wins + applySeaControl(state, [ + { zone: 'pacific', by: 'a1' }, + { zone: 'pacific', by: 'b2' }, + ]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]); + }); + + it('a contest tie: the earlier-listed op wins', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, + ); + // equal military (50 == 50) → a1 wins (listed first) + applySeaControl(state, [ + { zone: 'pacific', by: 'a1' }, + { zone: 'pacific', by: 'b2' }, + ]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + }); + + it('two ops for the same nation on the same zone: a single claim (no contest)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + ); + applySeaControl(state, [ + { zone: 'pacific', by: 'a1' }, + { zone: 'pacific', by: 'a1' }, + ]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + }); + + it('resolves `by` by unique name (not just id)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + ); + applySeaControl(state, [{ zone: 'pacific', by: 'Alpha' }]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + }); + + it('an unresolvable `by` is dropped; if all ops on a zone drop, the zone is untouched', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + // ghost does not exist → dropped; pacific stays as a1 + applySeaControl(state, [{ zone: 'pacific', by: 'ghost' }]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + + // a NEW zone with only an unresolvable op → zone not added + applySeaControl(state, [{ zone: 'arctic', by: 'ghost' }]); + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + }); + + it('an ambiguous name (two nations share it) drops the op', () => { + const state = mkState( + { + a1: mkNation('a1', 'Twin', NS(80, 70, 60, 50), ['a-r1']), + a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), ['a-r2']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'a-r2': RS(20, 20, 20, 20) }, + ); + applySeaControl(state, [{ zone: 'pacific', by: 'Twin' }]); + // ambiguous → dropped + expect(state.seaZones).toEqual([]); + }); + + it('never touches `territory` or `unowned`', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) }, + ); + applySeaControl(state, [ + { zone: 'pacific', by: 'a1' }, + { zone: 'pacific', by: 'b2' }, + ]); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.nations.b2.territory).toEqual(['b-r1']); + expect(state.unowned).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// applySeaZoneBonuses +// --------------------------------------------------------------------------- + +describe('applySeaZoneBonuses', () => { + it('a controller earns +1 military and +1 economy (clamped, mirrored, re-derived)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + applySeaZoneBonuses(state); + // +1 to both military and economy, mirrored to the region, re-derived + // (single region → re-derive gives back the region's value) + expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50)); + expect(state.regionStats['a-r1']).toEqual(RS(51, 51, 50, 50)); + }); + + it('clamps to 0 and 100', () => { + // military at 99 → 100; economy at 1 → 2 + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(99, 1, 50, 50), ['a-r1']) }, + { 'a-r1': RS(99, 1, 50, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + applySeaZoneBonuses(state); + expect(state.nations.a1.stats.military).toBe(100); + expect(state.nations.a1.stats.economy).toBe(2); + }); + + it('a nation controlling several zones stacks the bonus', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [ + { zone: 'pacific', by: 'a1' }, + { zone: 'indian', by: 'a1' }, + ], + ); + applySeaZoneBonuses(state); + // +2 to both (one per zone) + expect(state.nations.a1.stats).toEqual(NS(52, 52, 50, 50)); + }); + + it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), []) }, + {}, + [{ zone: 'pacific', by: 'a1' }], + ); + applySeaZoneBonuses(state); + // direct mutation, no re-derive (no regions) + expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50)); + }); + + it('a stale controller (nation no longer in `nations`) is skipped; the claim is kept', () => { + // Build a state where the claim references a nation id that is not in `nations` + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ zone: 'pacific', by: 'ghost' }], + ); + applySeaZoneBonuses(state); + // ghost is not in nations → skipped; a1 is unaffected + expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50)); + // the claim is kept (not removed) + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'ghost' }]); + }); + + it('an empty seaZones list is a no-op', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [], + ); + applySeaZoneBonuses(state); + expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50)); + }); + + it('never touches `territory` or `unowned`', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + applySeaZoneBonuses(state); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.unowned).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object contract +// --------------------------------------------------------------------------- + +describe('sea ability', () => { + it('owns the "sea" field; default() is a fresh empty { control: [] }', () => { + expect(sea.field).toBe('sea'); + expect(sea.default()).toEqual({ control: [] }); + expect(sea.default()).not.toBe(sea.default()); + }); + + it('default() is not meaningful; a populated value is', () => { + expect(sea.isMeaningful(sea.default())).toBe(false); + expect(sea.isMeaningful({ control: [{ zone: 'pacific', by: 'a1' }] })).toBe(true); + }); + + it('validate() wraps the validator into the SeaOps shape', () => { + expect(sea.validate([{ zone: 'pacific', by: 'a1' }])).toEqual({ + control: [{ zone: 'pacific', by: 'a1' }], + }); + expect(sea.validate(undefined)).toEqual({ control: [] }); + }); + + it('the stub value equals default() (the stub never proposes a sea op)', () => { + expect(sea.stub()).toEqual({ control: [] }); + }); + + it('stateFields is [seaZones] (the durable GameState field)', () => { + expect(sea.stateFields).toEqual(['seaZones']); + }); + + it('payload() emits seaZones (even when empty / undefined)', () => { + expect(sea.payload({ seaZones: [] } as never)).toEqual({ seaZones: [] }); + expect(sea.payload({} as never)).toEqual({ seaZones: [] }); + expect(sea.payload({ seaZones: [{ zone: 'pacific', by: 'a1' }] } as never)).toEqual({ + seaZones: [{ zone: 'pacific', by: 'a1' }], + }); + }); + + it('apply() runs the ops then the per-turn bonus (no throw on a bad ref)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(80, 40, 20, 30), ['b-r1']), + }, + { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(80, 40, 20, 30) }, + ); + const ctx = { previous: state, actingTurn: state.turn }; + sea.apply( + state, + { + control: [ + { zone: 'pacific', by: 'a1' }, + { zone: 'pacific', by: 'b2' }, + // an unresolvable ref — must be dropped, not throw + { zone: 'arctic', by: 'ghost' }, + ], + }, + ctx, + ); + // contest: b2 (military 80) beats a1 (military 50) → b2 controls pacific + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]); + // b2 gets the +1/+1 bonus (it controls pacific) + expect(state.nations.b2.stats).toEqual(NS(81, 41, 20, 30)); + // a1 is unaffected + expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50)); + }); + + it('apply() with an empty control still applies the per-turn bonus to existing claims', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ zone: 'pacific', by: 'a1' }], + ); + const ctx = { previous: state, actingTurn: state.turn }; + sea.apply(state, { control: [] }, ctx); + // no new claims, but the existing claim's controller earns the bonus + expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]); + expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50)); + }); +}); diff --git a/server/abilities/sea.ts b/server/abilities/sea.ts new file mode 100644 index 0000000..d17740b --- /dev/null +++ b/server/abilities/sea.ts @@ -0,0 +1,210 @@ +// The SEA (naval / sea control) ability (v0.43.0, #65) as a modular +// LLM-ability (v0.40.0, #67) — the naval layer, fully parallel to but +// INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES +// (`SEA_ZONES` in shared/protocol.ts); each is under the SOLE CONTROL of at +// most one nation. Controlling a sea zone never moves land (`territory` / +// `unowned` are untouched) and moving land never touches a sea zone. +// +// Each turn the LLM declares `sea.control` ops — one op names the zone and +// the nation that (now) controls it. The engine resolves `by` (id first, then +// a case-insensitive unique name, via the shared `resolveNationRef`) and +// UPSERTS the zone's claim in `GameState.seaZones` (replace in place if the +// zone is already claimed, append if new). If two or more ops this turn claim +// the SAME zone for DIFFERENT resolved nations, the zone is CONTESTED: the +// higher current `stats.military` wins (a tie goes to the op listed earlier in +// the `control` array) — deterministic, no hidden randomness. An unresolvable +// `by` is dropped (never thrown). +// +// Every committed turn — INCLUDING a turn with an empty `control` — each +// existing claim's controller earns a small DETERMINISTIC bonus +// (`SEA_ZONE_BONUS = 1` military + 1 economy, clamped to 0–100, mirrored to +// the controller's regions and re-derived with the same 0-region / no-entry +// guards as the economy / migration layers); a claim whose nation no longer +// exists is skipped (the claim is kept, never thrown). The layer is DURABLE +// (unlike the turn-transient hybrid / disasters / migration layers): +// `GameState.seaZones` persists across turns and in SQLite, and the user +// payload always carries the current `seaZones` list (even when empty). + +import { + clampStat, + deriveNationStats, + isSeaZoneId, +} from '../../shared/protocol'; +export { SEA_ZONES, SEA_ZONE_IDS, isSeaZoneId, seaZoneName } from '../../shared/protocol'; +import type { SeaControlOp, SeaOps } from '../../shared/protocol'; +import type { GameState, Nation } from '../../shared/types'; +import { resolveNationRef, str } from './shared'; +import type { Ability } from './types'; + +/** + * (v0.43.0, #65) The DETERMINISTIC per-turn bonus each sea-zone controller + * earns (applied to BOTH `military` and `economy`, clamped to 0–100, mirrored + * to the controller's regions, and re-derived with the same 0-region / + * no-entry guards as the economy / migration layers). No hidden randomness. + */ +export const SEA_ZONE_BONUS = 1; + +/** + * (v0.43.0, #65) Validate the raw `sea` field of a turn into a safe + * {@link SeaOps}. Defensive like the rest of the parser — never throws. The + * raw value may be the object `{ control: [...] }` (the TurnResult shape) or + * a bare array (be lenient about either). Each op needs a valid `zone` (one of + * {@link SEA_ZONES}) and a non-empty `by` (the controlling nation's id or + * unique name, capped at 200 chars). An op with an unknown / missing `zone` or + * an empty / missing `by` is dropped. Resolution of `by` to a concrete nation + * id happens at apply time (`applySeaControl`), not here. A missing / + * malformed top-level value yields `{ control: [] }`. + */ +export function validateSeaOps(raw: unknown): SeaOps { + const out: SeaOps = { control: [] }; + let list: unknown[] | null = null; + if (Array.isArray(raw)) { + list = raw; + } else if (raw && typeof raw === 'object') { + const inner = (raw as Record).control; + if (Array.isArray(inner)) list = inner; + } + if (!list) return out; + for (const e of list) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + const zone = str(o.zone, 100); + if (!zone || !isSeaZoneId(zone)) continue; // unknown / missing zone: drop + const by = str(o.by, 200); + if (!by) continue; // empty / missing controller: drop + out.control.push({ zone, by }); + } + return out; +} + +/** + * (v0.43.0, #65) Upsert the turn's sea-control ops into `next.seaZones`, + * mutating in place and never throwing. Each op's `by` is resolved against the + * CURRENT state (id first, then a case-insensitive unique name) — an + * unresolvable ref is dropped. Per zone (in first-appearance order): a single + * surviving op SETS or REPLACES the zone's controller (replace the existing + * entry in place — preserving its position — or append a new one); two or + * more surviving ops on the same zone for DIFFERENT resolved nations make the + * zone CONTESTED — the higher current `stats.military` wins (a tie goes to the + * op listed earlier in the `control` array), and the winner becomes the + * controller. A zone whose ops all resolve to the same nation is simply (re-) + * set to that nation. Sea control never touches `territory` / `unowned`. + */ +export function applySeaControl(next: GameState, ops: SeaControlOp[]): void { + // Group the ops by zone, keeping the original (first-appearance) order of + // zones and each zone's ops (so the contest tie-break "earlier in the + // control array" is well-defined). + const byZone = new Map(); + ops.forEach((op, index) => { + const list = byZone.get(op.zone) ?? []; + list.push({ index, by: op.by }); + byZone.set(op.zone, list); + }); + + for (const [zone, list] of byZone) { + // Resolve each op's `by`; an unresolvable ref is dropped (the zone is + // still decided by the ops that do resolve). + const resolved = list + .map(({ index, by }) => ({ index, id: resolveNationRef(next, by) })) + .filter((r): r is { index: number; id: string } => r.id !== null); + if (resolved.length === 0) continue; // nothing resolved: zone untouched + + // Contest: the highest current `stats.military` wins; a strict `>` keeps + // the EARLIER-listed op on a tie (the list is in control-array order). + let winner = resolved[0]; + for (const r of resolved) { + const mw = next.nations[winner.id]?.stats.military ?? 0; + const mr = next.nations[r.id]?.stats.military ?? 0; + if (mr > mw) winner = r; + } + + const existing = next.seaZones.find((c) => c.zone === zone); + if (existing) { + existing.by = winner.id; // replace in place (position preserved) + } else { + next.seaZones.push({ zone, by: winner.id }); + } + } +} + +/** + * (v0.43.0, #65) The per-turn sea bonus, applied EVERY committed turn (not + * just to this turn's claims): each entry of `next.seaZones` grants its + * controller `SEA_ZONE_BONUS` military + `SEA_ZONE_BONUS` economy (clamped + * to 0–100, mirrored to every region the nation owns, then the nation's + * derived `stats` re-derived — the same 0-region / no-entry guards as the + * economy / migration layers, so a 0-region nation keeps its directly-mutated + * value). A nation controlling several zones stacks the bonus; a claim whose + * nation no longer exists (removed by a merge / split this turn) is skipped — + * the claim is kept, never thrown. Never throws. + */ +export function applySeaZoneBonuses( + next: GameState, + geoOf?: (key: string) => { area: number; population: number } | undefined, +): void { + if (next.seaZones.length === 0) return; + + // nation id -> {military, economy} delta (claims for the same nation stack). + const delta = new Map(); + for (const c of next.seaZones) { + const d = delta.get(c.by) ?? { military: 0, economy: 0 }; + d.military += SEA_ZONE_BONUS; + d.economy += SEA_ZONE_BONUS; + delta.set(c.by, d); + } + + const applyNation = (n: Nation, d: { military: number; economy: number }): void => { + (['military', 'economy'] as const).forEach((k) => { + n.stats[k] = clampStat(n.stats[k] + d[k]); + }); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; // a key with no entry is skipped + (['military', 'economy'] as const).forEach((k) => { + rs[k] = clampStat(rs[k] + d[k]); + }); + } + // Re-derive the nation's aggregate stats so the effect persists (the main + // recompute ran earlier this turn; without this the change would be washed + // out next turn). A nation with no regionStats entry keeps its + // directly-mutated `stats`. + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } + }; + + for (const [id, d] of delta) { + const n = next.nations[id]; + if (!n) continue; // a nation removed this turn: the bonus is dropped (claim kept) + applyNation(n, d); + } +} + +export const sea: Ability<'sea'> = { + field: 'sea', + default: () => ({ control: [] }), + validate: (raw) => validateSeaOps(raw), + apply: (state, data, ctx) => { + // (1) APPLY OPS: this turn's control ops upsert `GameState.seaZones` + // (a single op sets/replaces the zone's controller; a same-zone + // contest goes to the higher current `stats.military`, a tie to the + // earlier-listed op). + // (2) PER-TURN BONUS: EVERY committed turn — including this one, and + // including turns with an empty `control` — each existing claim's + // controller earns the deterministic military/economy bonus, so a + // zone that stays held keeps paying out. + applySeaControl(state, data.control); + applySeaZoneBonuses(state, ctx.geoOf); + }, + schemaLine: () => + ' "sea": {"control":[{"zone":"","by":""}]},', + constraint: () => + '- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"","by":""}) sets or replaces that zone\'s controller (the user message\'s "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone\'s controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).', + isMeaningful: (data) => data.control.length > 0, + stateFields: ['seaZones'], + stub: () => ({ control: [] }), + // Sea control is DURABLE (unlike the turn-transient hybrid / disasters / + // migration layers): the user payload ALWAYS carries the current `seaZones` + // list (even when empty) so the model knows who controls which zone. + payload: (ctx) => ({ seaZones: ctx.seaZones ?? [] }), +}; diff --git a/server/abilities/types.ts b/server/abilities/types.ts index 7e82575..b7b8e1f 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -12,16 +12,17 @@ import type { IndustryProject, RegionResources, RelationStatus, + SeaZoneClaim, TradeAgreement, } from '../../shared/types'; import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; /** - * The 9 per-feature LLM abilities a turn can exercise, by the `TurnResult` + * The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult` * field each one owns. This is the closed universe the ordered `ABILITIES` * registry (./index.ts) is built from: a turn's world changes are exactly the * core engine effects (events / stats / territory / no-man's-land / nation-ops - * / markers) plus, in order, these nine abilities. + * / markers) plus, in order, these eleven abilities. */ export type AbilityField = | 'takeovers' @@ -33,7 +34,8 @@ export type AbilityField = | 'leaders' | 'ideology' | 'disasters' - | 'migration'; + | 'migration' + | 'sea'; /** * Everything a single ability's `apply` may need from the surrounding turn. @@ -89,6 +91,7 @@ export interface UserPromptCtx { readonly embargoes?: Embargo[]; readonly nationResources?: Record; readonly industryProjects?: IndustryProject[]; + readonly seaZones?: SeaZoneClaim[]; } /** diff --git a/server/db.test.ts b/server/db.test.ts index 2faa001..a77bb41 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -38,6 +38,8 @@ function makeState(id: string): GameState { embargoes: [], // (v0.36.0, #59) no industry projects. industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; } @@ -244,6 +246,17 @@ describe('openDb', () => { db.close(); }); + it('(v0.43.0 #65) a legacy row saved before the seaZones field existed loads with it defaulted to []', () => { + const db = openDb(':memory:'); + const legacy = makeState('legacy'); + delete (legacy as { seaZones?: unknown[] }).seaZones; // a pre-v0.43 save (field absent) + db.save(legacy); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.seaZones).toEqual([]); + db.close(); + }); + it('delete removes a game by id and reports absence otherwise', () => { const db = openDb(':memory:'); db.save(makeState('a')); diff --git a/server/engine.test.ts b/server/engine.test.ts index ac11fad..958ad75 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -133,6 +133,8 @@ function baseState(): GameState { embargoes: [], // (v0.36.0, #59) no industry projects on a fresh state. industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; } @@ -570,7 +572,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -592,7 +594,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -613,7 +615,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -632,7 +634,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1089,7 +1091,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1106,7 +1108,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1129,7 +1131,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1155,7 +1157,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1173,7 +1175,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1263,7 +1265,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1378,7 +1380,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1685,9 +1687,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, 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: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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)', () => { @@ -1700,7 +1702,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1721,7 +1723,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); @@ -1742,7 +1744,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1765,7 +1767,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1798,7 +1800,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.playerNationId).toBe('uk'); // player still leads it @@ -1822,7 +1824,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no nation was renamed @@ -1849,7 +1851,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // neither was renamed (ambiguous) @@ -1875,7 +1877,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); const next = applyTurnResult(state, validated!); @@ -1901,7 +1903,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.uk.name).toBe('The Commonwealth'); @@ -1925,7 +1927,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.version).toBe(2); // exactly one bump for the whole turn @@ -1947,7 +1949,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1981,7 +1983,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); @@ -2005,13 +2007,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -2038,7 +2040,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped @@ -2085,7 +2087,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, @@ -2110,7 +2112,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2126,14 +2128,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2203,7 +2205,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), @@ -2278,6 +2280,8 @@ function mkTurnState( embargoes: [], // (v0.36.0, #59) no industry projects on a fresh state. industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; } @@ -2301,6 +2305,7 @@ function mkTurnResult(partial: Partial): TurnResult { ideology: partial.ideology ?? { changes: [] }, disasters: partial.disasters ?? { events: [] }, migration: partial.migration ?? { ops: [] }, + sea: partial.sea ?? { control: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3026,7 +3031,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); // the valid one survives, the empty-name one is dropped @@ -3414,7 +3419,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3435,7 +3440,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3456,7 +3461,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); }); diff --git a/server/engine.ts b/server/engine.ts index c1be510..8ec1831 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -16,6 +16,7 @@ import type { RegionResources, RegionStats, RoundItem, + SeaZoneClaim, StatKey, TradeAgreement, } from '../shared/types'; @@ -104,6 +105,14 @@ export { MIGRATION_SOURCE_EFFECTS, MIGRATION_DESTINATION_EFFECTS, LARGE_INFLUX_STABILITY_DIP, + validateSeaOps, + applySeaControl, + applySeaZoneBonuses, + SEA_ZONE_BONUS, + SEA_ZONES, + SEA_ZONE_IDS, + isSeaZoneId, + seaZoneName, } from './abilities'; // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- @@ -376,6 +385,14 @@ export function buildUserPrompt( * key on any nation) when undefined. */ nuclear?: Record, + /** + * (v0.43.0, #65) The game's current sea-zone control (each a `{ zone, by }` + * pair of the fixed sea zones and their controlling nation id). Emitted + * verbatim (even when empty) so the model knows which of the 12 fixed sea + * zones each nation currently controls and can re-claim or contest them via + * `TurnResult.sea`. Omitted entirely when undefined. + */ + seaZones?: SeaZoneClaim[], ): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; @@ -485,6 +502,7 @@ export function buildUserPrompt( embargoes, nationResources, industryProjects, + seaZones, }; Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks if (noMansLand !== undefined) payload.unowned = noMansLand; @@ -493,6 +511,7 @@ export function buildUserPrompt( Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry + Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted) return JSON.stringify(payload, null, 2); } @@ -633,6 +652,7 @@ export function validateTurnResult( ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, + sea: { control: [] }, markers: { add: [], remove: [] }, }; @@ -723,7 +743,7 @@ export function validateTurnResult( // list for the player to confirm/reject. result.nationOps = validateNationOps(o.nationOps); - // The 9 per-feature LLM abilities (v0.40.0, #67): each one's validate call + // The 11 per-feature LLM abilities (v0.40.0, #67): each one's validate call // is owned by its Ability module in the ABILITIES registry. The field's raw // value is passed through a.validate() which returns the validated field shape. for (const a of ABILITIES) { @@ -917,6 +937,9 @@ export function applyTurnResult( // (v0.36, #59) active industry projects; a legacy/corrupt clone without the // field starts empty (the turn then only ever adds to it). next.industry = next.industry ?? []; + // (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field + // starts empty (the turn then upserts this turn's claims into it). + next.seaZones = next.seaZones ?? []; const actingTurn = state.turn; // this action resolves the current turn next.turn = state.turn + 1; // then we advance to the next turn to play next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2); @@ -1594,6 +1617,9 @@ export async function runTurn(opts: { state.industry ?? [], // (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker). nuclearCapability, + // (v0.43.0, #65) the current sea-zone control (always emitted — even when + // empty — so the model knows who controls which of the fixed sea zones). + state.seaZones ?? [], ); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; diff --git a/server/index.ts b/server/index.ts index 4afe09f..f44964d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -190,6 +190,8 @@ app.post('/api/games', (req: Request, res) => { // declares them over time via TurnResult.industry (one per region, a // per-region SET). industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region diff --git a/server/map.test.ts b/server/map.test.ts index 87c4993..c53dc18 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -59,6 +59,8 @@ const state: GameState = { embargoes: [], // (v0.36.0, #59) the map is ownership-only; industry is unused here. industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. diff --git a/server/protocol.test.ts b/server/protocol.test.ts index 1508c58..870e5f2 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -48,6 +48,8 @@ function mkState( trades: [], embargoes: [], industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats']; else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row diff --git a/server/regions.test.ts b/server/regions.test.ts index 0e816c9..4f06b76 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -118,6 +118,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { embargoes: [], // (v0.36.0, #59) unused here (allTerritoryKeys is ownership-only). industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); diff --git a/server/stub.ts b/server/stub.ts index c33e340..e3f8e6e 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -100,6 +100,14 @@ export interface StubPayload { * deterministicTurn), so the industry path is covered by unit tests only. */ industry?: Array<{ regionKey: string; type: string }>; + /** + * (v0.43.0, #65) The currently active sea-zone control + * (`{ zone, by }` — fixed sea-zone id + controlling nation id), emitted by + * the engine's user prompt (always, even when empty). The stub ignores this + * field — it never claims, contests or re-claims a sea zone (see + * deterministicTurn), so the sea path is covered by unit tests only. + */ + seaZones?: Array<{ zone: string; by: string }>; } function truncate(s: string, n: number): string { @@ -173,6 +181,18 @@ function truncate(s: string, n: number): string { * (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it * always returns `ideology: { changes: [] }`) — the ideology path is covered * by unit tests only, keeping the e2e assertions untouched. + * + * (v0.41.0, #63) The stub likewise NEVER declares a disaster or pandemic (it + * always returns `disasters: { events: [] }`) — the disasters path is covered + * by unit tests only, keeping the e2e assertions untouched. + * + * (v0.42.0, #64) The stub likewise NEVER declares a migration / refugee flow + * (it always returns `migration: { ops: [] }`) — the migration path is + * covered by unit tests only, keeping the e2e assertions untouched. + * + * (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea + * zone (it always returns `sea: { control: [] }`) — the sea-control path is + * covered by unit tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -252,13 +272,13 @@ export function deterministicTurn(p: StubPayload): TurnResult { nationOps: [], // the stub never proposes nation ops (see doc comment) }; - // The 9 ability fields, filled by the registry in its canonical order. The + // The 11 ability fields, filled by the registry in its canonical order. The // stub is ability-inert: every ability contributes its own no-op default // (each ability's `stub()` — see the per-ability doc comments above), so the // e2e assertions are unaffected. The insertion order here (ABILITIES) is the // historical key order pinned by the stub's deterministic output // (takeovers → wars → hybrid → economy → industry → nuclear → leaders → - // ideology → disasters), which must stay byte-identical. + // ideology → disasters → migration → sea), which must stay byte-identical. const abilities = {} as Record; for (const a of ABILITIES) abilities[a.field] = a.stub(); diff --git a/shared/protocol.ts b/shared/protocol.ts index 1553110..74af422 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -707,6 +707,82 @@ export interface MigrationOps { ops: MigrationOp[]; } +// --------------------------------------------------------------------------- +// (v0.43.0, #65) Sea zones & sea control — the naval layer, fully parallel to +// but INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES +// ({@link SEA_ZONES}); each is under the SOLE CONTROL of at most one nation. +// Controlling a sea zone never moves land (`territory` / `unowned` are +// untouched) and moving land never touches a sea zone. `SeaZoneClaim` (the +// durable per-zone record, `{ zone, by }`) lives in `shared/types.ts` like +// `Trade` / `Embargo` / `War` / `IndustryProject`; `GameState.seaZones` is a +// list of them, unique per zone (`[]` at creation; a legacy SQLite row +// without the field loads as `[]` — additive, never wiped). Each turn the LLM +// declares `TurnResult.sea.control` ops — one op names the zone and the +// nation that (now) controls it. The engine resolves `by` (id first, then a +// case-insensitive unique name) and UPSERTS the zone's claim (replace in +// place if the zone is already claimed, append if new). If two or more ops +// this turn claim the SAME zone for DIFFERENT resolved nations, the zone is +// CONTESTED: the higher current `stats.military` wins (a tie goes to the op +// listed earlier in the `control` array) — deterministic, no hidden +// randomness; an unresolvable `by` is dropped (never thrown). Every committed +// turn — including a turn with an empty `control` — each existing claim's +// controller earns a small DETERMINISTIC bonus (`SEA_ZONE_BONUS = 1` military +// + 1 economy, clamped to 0–100, mirrored to the controller's regions and +// re-derived with the same 0-region / no-entry guards as the economy / +// migration layers); a claim whose nation no longer exists is skipped (the +// claim is kept, never thrown). The layer is DURABLE (unlike the turn- +// transient hybrid / disasters / migration layers): `GameState.seaZones` +// persists across turns and in SQLite. The user payload carries the current +// `seaZones` list so the model knows who controls which zone. + +/** The 12 named sea zones the world model knows (v0.43.0, #65). */ +export const SEA_ZONES = [ + { id: 'north-atlantic', name: 'North Atlantic' }, + { id: 'south-atlantic', name: 'South Atlantic' }, + { id: 'pacific', name: 'Pacific Ocean' }, + { id: 'indian', name: 'Indian Ocean' }, + { id: 'mediterranean', name: 'Mediterranean Sea' }, + { id: 'north-sea', name: 'North Sea' }, + { id: 'baltic', name: 'Baltic Sea' }, + { id: 'arctic', name: 'Arctic Ocean' }, + { id: 'black-sea', name: 'Black Sea' }, + { id: 'gulf-mexico', name: 'Gulf of Mexico' }, + { id: 'red-sea', name: 'Red Sea' }, + { id: 'sargasso', name: 'Sargasso Sea' }, +] as const; + +/** The set of valid sea-zone ids (v0.43.0, #65). */ +export const SEA_ZONE_IDS: ReadonlySet = new Set(SEA_ZONES.map((z) => z.id)); + +/** Whether `id` is one of the fixed sea-zone ids (v0.43.0, #65). */ +export function isSeaZoneId(id: string): boolean { + return SEA_ZONE_IDS.has(id); +} + +/** The display name of a sea-zone id; an unknown id is returned as-is (v0.43.0, #65). */ +export function seaZoneName(id: string): string { + return SEA_ZONES.find((z) => z.id === id)?.name ?? id; +} + +/** + * A single sea-control op (v0.43.0, #65): `zone` is one of the fixed + * {@link SEA_ZONES} ids and `by` is the nation that (now) controls it (by id + * or name). One op SETS or REPLACES the zone's controller; two or more ops on + * the same zone for different nations are CONTESTED — the higher current + * `stats.military` wins (a tie: the earlier-listed op). + */ +export interface SeaControlOp { + /** The sea zone (one of {@link SEA_ZONE_IDS}). */ + zone: string; + /** The controlling nation (by id or name). */ + by: string; +} + +/** The sea-control block of a `TurnResult`. Required; default `{ control: [] }`. */ +export interface SeaOps { + control: SeaControlOp[]; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -899,6 +975,23 @@ export interface TurnResult { * `{ ops: [] }`. */ migration: MigrationOps; + /** + * (v0.43.0, #65) Sea-zone control ops this turn — the naval layer, fully + * parallel to but INDEPENDENT of land: each op names one of the 12 fixed + * sea zones (`SEA_ZONES`) and the nation that now controls it. The engine + * resolves `by` (id first, then a case-insensitive unique name) and + * upserts the zone's claim in `GameState.seaZones` (a single op sets or + * replaces the controller; two or more ops on the same zone for different + * nations are CONTESTED — the higher current `stats.military` wins, a tie + * goes to the op listed earlier in `control`; an unresolvable `by` is + * dropped, never thrown). Every committed turn, each existing claim's + * controller earns a small deterministic military/economy bonus + * (`SEA_ZONE_BONUS`). Sea control never moves land: `territory` and + * `unowned` are untouched. At most 1–2 per turn, each narrated in `events` + * (a `military` or `diplomatic` event). Required; default + * `{ control: [] }`. + */ + sea: SeaOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small diff --git a/shared/scenario.ts b/shared/scenario.ts index 2245023..3a46289 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -461,6 +461,8 @@ export function worldDraftToGameState( embargoes: [], // (v0.36, #59) No industry projects on a fresh (dynamic) game. industry: [], + // (v0.43.0, #65) no sea-zone control on a fresh state. + seaZones: [], }; return state; } diff --git a/shared/types.ts b/shared/types.ts index 9a05de2..031cee2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -249,6 +249,23 @@ export interface IndustryProject { type: IndustryType; } +/** + * (v0.43.0, #65) One sea-zone claim: which nation controls one of the fixed + * sea zones (see `SEA_ZONES` in `shared/protocol.ts`). The world keeps at + * most ONE claim per zone — a new op on an already-claimed zone REPLACES the + * controller in place (the zone's entry is updated, never duplicated), and if + * a turn declares the same zone for two different resolved nations the higher + * current `stats.military` wins (a tie: the op listed earlier in the turn's + * `control` array). Sea control is fully parallel to but INDEPENDENT of land: + * it never touches `territory` / `unowned`. + */ +export interface SeaZoneClaim { + /** The sea zone (one of `SEA_ZONE_IDS`). */ + zone: string; + /** The id of the nation that controls it (resolved, not a name). */ + by: string; +} + /** * (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 @@ -443,6 +460,20 @@ export interface GameState { * with this defaulted to `[]` (see server/db.ts) — additive, no data wipe. */ industry: IndustryProject[]; + /** + * (v0.43.0, #65) Sea-zone control (see {@link SeaZoneClaim}): one entry per + * controlled sea zone — which of the 12 fixed zones (`SEA_ZONES`) each + * nation controls. At most ONE entry per zone (unique by `zone`); a turn's + * `sea.control` ops upsert entries (a re-claim by a different nation + * replaces the controller in place; a same-zone contest goes to the higher + * current `stats.military`, a tie to the earlier-listed op). Every + * committed turn each claim's controller earns a small deterministic + * military/economy bonus (`SEA_ZONE_BONUS`). Sea control never moves land + * (`territory` / `unowned` are untouched). Always `[]` on a fresh game; + * rows saved before v0.43 load with this defaulted to `[]` (see + * server/db.ts) — additive, no data wipe. + */ + seaZones: SeaZoneClaim[]; } /**