feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s
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:
@@ -2,6 +2,20 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.44.0 — 2026-10-16 15:10
|
||||
- **#66: Contested regions / frontlines — a `TurnResult.contested` lever letting the LLM declare a region a **contested** war frontline (a **third region state**: in **no** nation's `territory` and **not** in `unowned` / no-man's-land) and resolve existing frontlines.** It lands as the **12th ability** of the v0.40.0 modular system, in a new `server/abilities/contested.ts` module, **registered last** in the `ABILITIES` registry (after `sea`). Like the `sea` and `industry` layers it is **durable**: `GameState.contested` persists across turns and in SQLite. It is a **pure territory-state** layer — the contested key simply *leaves* ownership (sole ownership is never broken: a key is in at most one `territory`), it is never placed into two territories, and it never touches borders directly.
|
||||
- **Types (`shared/protocol.ts`).** New `ContestOp` (`{ regionKey; parties }` — `regionKey` is a region key or resolvable name, `parties` are two or more nation refs (ids or names)), `ContestedOps` (`{ open: ContestOp[]; resolve: string[] }`), and `ContestedRegion` (`{ regionKey; parties }` — `parties` store the **resolved** nation ids); `TurnResult.contested` is **required**, placed between `sea` and `markers`, default `{ open: [], resolve: [] }`. New `CONTESTED_FILL = '#f97316'` / `CONTESTED_STROKE` (the distinct visible frontline fill/stroke for the map — a reddish/orange "hot" frontline on the dark basemap, clearly distinct from a nation color and from the no-man's-land grey) and `contestedTooltipLabel(parties)` (a short `"Contested: A vs B"` hover sub-line; a missing party name degrades to a dash, never crashes). `ContestedRegion` is imported into `shared/types.ts`; `GameState.contested: ContestedRegion[]` 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/contested.ts`).** `field: 'contested'`; `default()` / `stub()` → a **fresh** `{ open: [], resolve: [] }` each call; `validate` wraps the exported `validateContestedOps` (defensive, never throws — accepts the `{ open, resolve }` object; an `open` op needs a non-empty `regionKey` (trimmed, ≤120) and **two or more** non-empty `party` refs (each trimmed, ≤200), a `resolve` entry a non-empty key (trimmed, ≤120); missing / malformed → dropped; a missing / malformed top-level object yields `{ open: [], resolve: [] }`); `apply` runs the exported `applyContested`; `schemaLine()` / `constraint()` supply the prompt text; **`payload()` → `{ contested: [{ regionKey, parties: [NAMES] }] }`** (the current frontlines with party **names** resolved from the stored ids) **only when non-empty** — `{}` on a fresh / no-frontline world (like the turn-transient layers, unlike `sea` which always emits); `isMeaningful` = `open.length > 0 || resolve.length > 0`; **`stateFields: ['contested']`** (the durable `GameState` field, for the `db.ts` legacy-row migration).
|
||||
- **Open semantics (`applyContested`, OPEN phase).** Each `open` op's `regionKey` resolves to a concrete key (exact key first, then a unique case-insensitive name via the region manifest — with no manifest loaded the raw string is accepted; a key absent from the loaded manifest is dropped). Its `parties` resolve via the shared `resolveNationRef` (id first, then a unique name; unresolvable / ambiguous refs dropped, order preserved, duplicates removed) — an op left with **fewer than two distinct resolved parties** is dropped. A region is **contestable** only when either (a) it has a **sole owner** that is at war (a shared **active** `War`) with at least one of the parties, or (b) it has **zero owners AND is in `unowned`** (no man's land) and **two of the parties are at war with each other**. A region with **two or more owners** (corrupt), or whose owner is **not at war with any party**, is never contestable (dropped — an ended / armistice war does not count). On a successful open the key is **removed from its sole owner's `territory`** (or from `unowned` for a no-man's-land region), its `regionStats` entry is **deleted**, and a `{ regionKey, parties: [ids] }` entry is added to `next.contested` (**unique by `regionKey`** — a re-open replaces any kept entry for the same key). The stripped owner's derived `stats` are **re-derived** (the same 0-region / no-entry guards as the other layers, so a 0-region owner keeps its directly-mutated value).
|
||||
- **Resolve semantics (`applyContested`, RESOLVE phase — runs FIRST).** Each key in `resolve` that is currently in `next.contested` is removed and its region **released to no man's land** (out of any territory, into `unowned`, `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 its `regionStats` is **kept** (or **re-initialised** as a copy of the owner's stats if absent). A key that is not currently contested is ignored (no-op). A resolved key owned by a **non-party** (defensive) is released as above.
|
||||
- **Territory-step guard (`engine.ts applyTurnResult`).** A **currently-contested** key (in `state.contested`) sits in no nation's territory, so a normal `gain` would otherwise be treated as a free re-claim. The claims step is guarded: a normal claim on a contested key proceeds **only** when this same turn `resolve`s the contest **AND** the gainer is one of the contesting **parties** (a party re-claim); otherwise the frontline **stays contested** for the contested layer (which runs later in the turn) to decide its fate. A still-contested key is therefore never a valid normal-claim target.
|
||||
- **Map (client `WorldMap.tsx`).** The contested state is decided by the client from `GameState.contested` (like no-man's-land), **never by the `RegionMap`** (which stays owned-vs-absent). A contested key is rendered with the distinct `CONTESTED_FILL` / `CONTESTED_STROKE` and a `"Contested: A vs B"` hover sub-line; the contested check runs **first** (before unowned / owned) in the fill-resolution, and the hover handler resolves the party names from `GameState.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 it adds no legend entry — e2e legend assertions unaffected). `App.tsx` passes `contested={game.contested ?? []}` to `<WorldMap>`.
|
||||
- **Registry & prompt.** `contested` is added **last** in `ABILITIES` (existing order untouched — the 12th entry); the system prompt gains the `contested` schema line and its constraint bullet ("a contested region is in NO nation's territory (never two) and is never no man's land while contested; at most 1–2 contested-region changes per turn, each narrated in `events`"); the user prompt gains `"contested"` (region key + party **names**) **only when the list is non-empty** (the one durable ability whose `payload()` can be `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `contested` schema line after `sea` + constraint bullet before the "Prefer 1-4 events" bullet); the **`user.txt` golden gains a `"contested"` entry** (the sample `b-r1` frontline with party names) as a new key after `seaZones`.
|
||||
- **Tests.** New `server/abilities/contested.test.ts` (**52 tests**): the `CONTESTED_FILL` / `CONTESTED_STROKE` / `contestedTooltipLabel` surface; `validateContestedOps` (shape / leniency / drops / field caps / malformed top-level); `applyContested` OPEN (sole-owner-at-war with owner-as-party / party-not-owner, owner-not-at-war drop, no-man's-land open + NML-with-warring-parties + not-in-unowned drop, corrupt 2-owner drop, armistice-not-a-war drop, distinct-party / unresolvable / ambiguous drops, region-name + party-name resolve, manifest-gated key drop, owner stats re-derive + 0-region guard, two-opens-on-same-key, multi-key open); RESOLVE (release-to-NML, party-re-claim keeps + re-inits stats, non-party-owner release, multi-frontline, non-contested no-op, empty no-op); ordering (re-open replaces, resolve-then-open from NML, still-contested not re-openable, combined apply); and the ability const contract (field, `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload()` id→name / missing-nation fallback / `{}` when empty, `validate` wrapping, `apply` ctx wiring). Every apply test asserts the **sole-ownership invariants** (no key in >1 territory; a contested key in no territory and not unowned; an unowned key in no territory and not contested). `registry.test.ts` is updated for the **12-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields`, composes 13 / `extended[12]`, re-export pin, user-golden 15th arg). New `db.test.ts` legacy-row test (a pre-v0.44 save without `contested` loads with `[]`).
|
||||
- **Design note (deviation from the issue body).** The Gitea #66 body sketched a **two-owner** design (`disputed` regions with `fronts`, explicitly "relaxing sole-ownership"). The approved, implemented design is a **third region state**: a contested key is in **no** nation's `territory` and not in `unowned` (sole ownership is preserved — a key is never in two territories), with `open` / `resolve` ops and a **minimum of two contesting parties** tied to an active war. This keeps the engine's sacrosanct sole-ownership invariant intact while still modelling a war frontline.
|
||||
- **Guarantees.** No new runtime deps, `shared/` keeps zero runtime deps, no `@turf`/`@types/geojson` in any `.ts`. No engine randomness (the LLM declares; the engine applies fixed, deterministic rules). All existing unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is contested-inert — it never opens or resolves a frontline, and the map legend stays at 7).
|
||||
- **Version** bumped `0.43.0 → 0.44.0`.
|
||||
|
||||
## 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).
|
||||
|
||||
Reference in New Issue
Block a user