feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s

A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
This commit is contained in:
2026-10-16 15:21:45 +00:00
parent d946fdf0d7
commit 5477336537
27 changed files with 1743 additions and 80 deletions
+47 -1
View File
@@ -104,7 +104,7 @@ history/
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
│ │ │ # migration (v0.42.0 #64)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 10-ability contract)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 12-ability contract)
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
│ └── *.test.ts # unit tests (vitest)
└── src/
@@ -583,6 +583,52 @@ history/
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.
- **Contested regions / frontlines — a `TurnResult.contested` lever opening/resolving war frontlines: a THIRD region state in no nation's `territory` and not in `unowned` (v0.44.0, #66).**
`TurnResult.contested = ContestedOps = { open: ContestOp[]; resolve: string[] }` (required,
default `{ open: [], resolve: [] }`); `ContestOp = { regionKey, parties }` where `regionKey` is
a region key or resolvable name and `parties` are TWO OR MORE nation refs (ids or names, via
the shared `resolveNationRef`). Like the `sea` / `industry` layers this is **durable**: it owns
the `GameState.contested: ContestedRegion[]` field (`ContestedRegion = { regionKey, parties }`
— `parties` store the RESOLVED nation ids; 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). `applyContested` (run LAST in the `ABILITIES` loop, after
territory / wars / economy / sea — it sees the final post-claim ownership) runs **RESOLVE
first, then OPEN**: a `resolve` removes a currently-contested key and **releases it to no
man's land** (into `unowned`, out of any territory, `regionStats` deleted) — UNLESS a
concurrent winning claim by one of the contesting **parties** already re-owned it this turn
(the only claim the territory step permits on a contested key), in which case it stays owned
and keeps (or re-inits) its `regionStats`; an `open` makes a region a frontline only when it
is **contestable** — a **sole owner** at war (a shared **active** `War`) with at least one
party, or a **no-man's-land** region (0 owners AND in `unowned`) whose two parties are at war
with each other — a 2+-owner key or an owner not at war with any party is **dropped**
(an armistice/ended war does not count); on a successful open the key is **stripped from its
owner's `territory` (or from `unowned`)**, its `regionStats` entry deleted, and a
`{ regionKey, parties }` entry added to `next.contested` (**unique by `regionKey`** — a
re-open replaces a kept entry), with the stripped owner's derived stats re-derived (same
0-region / no-entry guards). The **sacrosanct sole-ownership invariant is never broken**: a
contested key is in **no** territory (never two) and is **never** in `unowned` while
contested (the two states are mutually exclusive). The **territory step in `engine.ts` is
guarded**: a normal `gain` on a currently-contested key is **dropped** unless this same turn
resolves the contest AND the gainer is one of the contesting parties (a party re-claim) — a
still-contested key is never a valid normal-claim target. The **map (client-only, like
no-man's-land)**: `WorldMap` renders a contested key in the distinct visible
`CONTESTED_FILL` / `CONTESTED_STROKE` (orange "hot" frontline) with a `"Contested: A vs B"`
hover sub-line (names from `GameState.contested`; a removed party degrades to a dash) — the
contested check runs **first** (before unowned / owned); the server `RegionMap` stays
owned-vs-absent (it never knows about contested). **The map legend stays at 7 items** (it is
derived from the `RegionMap` owners only — a contested key is absent from the `RegionMap`, so
no new legend entry; e2e legend assertions are unaffected). `isMeaningful` is
`open.length > 0 || resolve.length > 0`; the system prompt documents the field + constraints
(at most 1–2 contested-region changes per turn, each narrated in `events`); the user payload
carries the current frontlines (region key + party **names**) **only when non-empty** —
`payload()` → `{}` on a fresh / no-frontline world (unlike `sea`, which always emits). The
**stub never** opens or resolves a frontline (`{ open: [], resolve: [] }`), so e2e (CI,
`LLM_STUB=1`) is unaffected and the path is unit-tested only
(`server/abilities/contested.test.ts`, 52 tests, each asserting the sole-ownership invariants).
**Design note (deviation from the issue body):** the Gitea #66 body sketched a **two-owner**
`disputed` / `fronts` design that "relaxes sole-ownership"; the implemented, approved design
is this **third region state** (a contested key in NO territory, sole-ownership preserved)
with `open` / `resolve` ops and a minimum of two contesting parties.
- **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