feat: #65 sea control — 11th ability (v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s

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.
This commit is contained in:
2026-10-16 12:50:22 +00:00
parent 13f91845fd
commit c77e81c45b
24 changed files with 1060 additions and 58 deletions
+35
View File
@@ -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