diff --git a/AGENTS.md b/AGENTS.md index aa34ec9..e4520b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ history/ │ │ ├── .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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7707aa1..eedc99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``. + - **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). diff --git a/package.json b/package.json index f7fd2f1..0f460ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.43.0", + "version": "0.44.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/contested.test.ts b/server/abilities/contested.test.ts new file mode 100644 index 0000000..d81261d --- /dev/null +++ b/server/abilities/contested.test.ts @@ -0,0 +1,1011 @@ +// Unit tests for the CONTESTED (contested regions / frontlines) ability +// (v0.44.0, #66) — the modular, DURABLE LLM-ability that introduces a THIRD +// region state, besides "owned by one nation" and "no man's land": a war +// frontline that is in **no** nation's `territory` and not in `unowned`. +// `open` moves a contestable region (a sole owner at war with a party, or a +// no-man's-land region whose parties are at war with each other) out of +// ownership into `GameState.contested`; `resolve` ends a frontline, releasing +// the region to no man's land unless a party's concurrent claim re-owns it. +// The layer never places a key in two territories (sole ownership is +// sacrosanct), never touches borders directly, and is deterministic. +// 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 opens or resolves a contested region, +// so the apply path is exercised here, not by the e2e suite. + +import { describe, expect, it } from 'vitest'; + +import { + CONTESTED_FILL, + CONTESTED_STROKE, + applyContested, + contested, + contestedTooltipLabel, + validateContestedOps, +} from './contested'; + +import type { AbilityCtx, UserPromptCtx } from './types'; +import type { + ContestedRegion, + ContestedOps, +} from '../../shared/protocol'; +import type { + GameState, + Nation, + NationStats, + RegionStats, + War, +} 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 War entity (active by default). */ +function mkWar(id: string, sides: string[], status: War['status'] = 'active'): War { + return { id, sides, declaredTurn: 1, status }; +} + +/** + * A minimal GameState carrying the given nations (keyed by id) and per-region + * stats, plus optional wars / unowned / contested fixtures. Every other + * required field is at its fresh-game baseline. + */ +function mkState( + nations: Record, + regionStats: Record, + opts: { + wars?: War[]; + unowned?: string[]; + contested?: ContestedRegion[]; + } = {}, +): 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: opts.unowned ?? [], + pendingNationOps: [], + markers: [], + regionStats, + wars: opts.wars ?? [], + trades: [], + embargoes: [], + industry: [], + seaZones: [], + contested: opts.contested ?? [], + }; +} + +/** The minimal AbilityCtx the contested apply path needs (it reads only `next`). */ +function ctx(state: GameState): AbilityCtx { + return { previous: state, actingTurn: state.turn }; +} + +/** + * The sacrosanct invariants, asserted after every op: no region key is in more + * than one nation's territory; a contested key is in NO territory and NOT in + * `unowned`; an unowned key is in no territory and not contested. + */ +function assertSoleOwnership(state: GameState): void { + const owners = new Map(); + for (const n of Object.values(state.nations)) { + for (const k of n.territory) owners.set(k, (owners.get(k) ?? 0) + 1); + } + for (const [k, c] of owners) expect(c, `key ${k} has ${c} owners`).toBe(1); + const contestedKeys = new Set(state.contested.map((c) => c.regionKey)); + for (const k of contestedKeys) { + expect(owners.get(k) ?? 0, `contested key ${k} must be in no territory`).toBe(0); + expect(state.unowned, `contested key ${k} must not be no man's land`).not.toContain(k); + } + for (const k of state.unowned) { + expect(owners.get(k) ?? 0, `unowned key ${k} must be in no territory`).toBe(0); + expect(contestedKeys.has(k), `unowned key ${k} must not be contested`).toBe(false); + } +} + +// --------------------------------------------------------------------------- +// Map-fill constants / tooltip label (re-exported from the shared protocol) +// --------------------------------------------------------------------------- + +describe('CONTESTED_FILL / CONTESTED_STROKE / contestedTooltipLabel', () => { + it('exposes the distinct frontline fill + stroke', () => { + expect(CONTESTED_FILL).toBe('#f97316'); + expect(CONTESTED_STROKE).toBe('rgba(255,237,213,0.55)'); + }); + + it('contestedTooltipLabel joins the party names with " vs " ("Contested: A vs B")', () => { + expect(contestedTooltipLabel(['Alpha', 'Bravo'])).toBe('Contested: Alpha vs Bravo'); + expect(contestedTooltipLabel(['Alpha'])).toBe('Contested: Alpha'); + }); + + it('a missing party name degrades to a dash, never crashes', () => { + expect(contestedTooltipLabel(['', 'Bravo'])).toBe('Contested: — vs Bravo'); + expect(contestedTooltipLabel([])).toBe('Contested: '); + }); +}); + +// --------------------------------------------------------------------------- +// validateContestedOps +// --------------------------------------------------------------------------- + +describe('validateContestedOps', () => { + it('accepts the { open: [...], resolve: [...] } object shape', () => { + const open = { regionKey: 'x-r1', parties: ['a1', 'b2'] }; + expect(validateContestedOps({ open: [open], resolve: ['x-r2'] })).toEqual({ + open: [open], + resolve: ['x-r2'], + }); + }); + + it('a missing sub-key yields an empty list for it', () => { + expect(validateContestedOps({})).toEqual({ open: [], resolve: [] }); + expect(validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] })).toEqual({ + open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], + resolve: [], + }); + }); + + it('drops an open op with a missing / empty / non-string regionKey', () => { + expect(validateContestedOps({ open: [{ parties: ['a1', 'b2'] }] })).toEqual({ + open: [], + resolve: [], + }); + expect(validateContestedOps({ open: [{ regionKey: ' ', parties: ['a1', 'b2'] }] })).toEqual({ + open: [], + resolve: [], + }); + expect( + validateContestedOps({ open: [{ regionKey: 42, parties: ['a1', 'b2'] }] }), + ).toEqual({ open: [], resolve: [] }); + }); + + it('drops an open op whose parties is not an array', () => { + expect( + validateContestedOps({ open: [{ regionKey: 'x-r1', parties: 'a1' }] }), + ).toEqual({ open: [], resolve: [] }); + expect( + validateContestedOps({ open: [{ regionKey: 'x-r1' }] }), + ).toEqual({ open: [], resolve: [] }); + }); + + it('drops an open op with fewer than two party refs (including non-strings filtered out)', () => { + expect(validateContestedOps({ open: [{ regionKey: 'x-r1', parties: [] }] })).toEqual({ + open: [], + resolve: [], + }); + expect( + validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1'] }] }), + ).toEqual({ open: [], resolve: [] }); + // a non-string party is filtered out, leaving one → dropped + expect( + validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1', 42] }] }), + ).toEqual({ open: [], resolve: [] }); + }); + + it('keeps an open op with two or more party refs (refs stay raw — resolved at apply time)', () => { + expect( + validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo', 'c3'] }] }), + ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo', 'c3'] }], resolve: [] }); + }); + + it('drops a non-object open entry', () => { + expect( + validateContestedOps({ open: [42, 'nope', null, { regionKey: 'x-r1', parties: ['a1', 'b2'] }] }), + ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }); + }); + + it('keeps non-empty resolve keys and drops empty / non-string entries', () => { + expect( + validateContestedOps({ resolve: ['x-r1', '', null, 42, 'x-r2'] }), + ).toEqual({ open: [], resolve: ['x-r1', 'x-r2'] }); + }); + + it('caps regionKey at 120 chars and party refs at 200 chars', () => { + const out = validateContestedOps({ + open: [{ regionKey: 'k'.repeat(300), parties: ['p'.repeat(300), 'b2'] }], + }); + expect(out.open).toHaveLength(1); + expect(out.open[0].regionKey).toHaveLength(120); + expect(out.open[0].parties[0]).toHaveLength(200); + const out2 = validateContestedOps({ resolve: ['k'.repeat(300)] }); + expect(out2.resolve[0]).toHaveLength(120); + }); + + it('a missing / malformed top-level value yields { open: [], resolve: [] }', () => { + expect(validateContestedOps(undefined)).toEqual({ open: [], resolve: [] }); + expect(validateContestedOps(null)).toEqual({ open: [], resolve: [] }); + expect(validateContestedOps('nope')).toEqual({ open: [], resolve: [] }); + // a bare array is NOT accepted (unlike some sibling abilities) + expect(validateContestedOps([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }])).toEqual({ + open: [], + resolve: [], + }); + expect(validateContestedOps({ open: 'nope', resolve: 'nope' })).toEqual({ + open: [], + resolve: [], + }); + }); +}); + +// --------------------------------------------------------------------------- +// applyContested — OPEN +// --------------------------------------------------------------------------- + +describe('applyContested — open', () => { + it('a sole owner at war with a party: the key leaves the owner, becomes contested (owner may be a party)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + // a1 (the owner) and b2 are both parties; the owner is at war with b2 → open + applyContested(state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state)); + expect(state.nations.a1.territory).toEqual([]); + expect(state.unowned).toEqual([]); // NOT released to no man's land + expect(state.regionStats['x-r1']).toBeUndefined(); // the per-region stats leave + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + assertSoleOwnership(state); + }); + + it('a party at war with the owner (the owner is not itself a party): open', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + // owner a1 is at war with party b2 (c3 is a bystander party) → open + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['b2', 'c3'] }], resolve: [] }, + ctx(state), + ); + expect(state.nations.a1.territory).toEqual([]); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['b2', 'c3'] }]); + assertSoleOwnership(state); + }); + + it('a sole owner not at war with any party: dropped (no contest, territory intact)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['b2', 'c3'])] }, + ); + // the parties (b2, c3) are at war with EACH OTHER, but the owner (a1) is at + // war with neither — the NML branch does not apply to an owned region + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['b2', 'c3'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + expect(state.regionStats['x-r1']).toEqual(RS(10, 10, 10, 10)); + assertSoleOwnership(state); + }); + + it('a no-man’s-land region (in `unowned`) whose two parties are at war: open (key leaves `unowned`)', () => { + 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) }, + { wars: [mkWar('w1', ['a1', 'b2'])], unowned: ['x-r2'] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.unowned).toEqual([]); + expect(state.contested).toEqual([{ regionKey: 'x-r2', parties: ['a1', 'b2'] }]); + assertSoleOwnership(state); + }); + + it('a no-man’s-land region whose parties are NOT at war with each other: dropped', () => { + 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) }, + { unowned: ['x-r2'] }, // no wars at all + ); + applyContested( + state, + { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.unowned).toEqual(['x-r2']); // untouched + assertSoleOwnership(state); + }); + + it('a key with zero owners that is NOT in `unowned` (never assigned): dropped', () => { + 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) }, + { wars: [mkWar('w1', ['a1', 'b2'])], unowned: [] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + assertSoleOwnership(state); + }); + + it('a key with two or more owners (corrupt) is never contestable: dropped', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['x-r1']), // corrupt double-ownership + }, + { 'x-r1': RS(10, 10, 10, 10) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + // the (corrupt) ownership is left as-is — the layer never "fixes" it + expect(state.nations.a1.territory).toEqual(['x-r1']); + expect(state.nations.b2.territory).toEqual(['x-r1']); + }); + + it('an ended (armistice) war does not make the parties "at war": dropped', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'], 'armistice')] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + assertSoleOwnership(state); + }); + + it('fewer than two DISTINCT resolved parties (two refs to one nation): dropped', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + // 'Alpha' resolves to a1 — the same nation as 'a1' → only one distinct party + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'Alpha'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + assertSoleOwnership(state); + }); + + it('an unresolvable party ref is dropped; if fewer than two parties remain, the op is dropped', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'ghost'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + assertSoleOwnership(state); + }); + + it('an ambiguous party name (two nations share it) is dropped like an unknown ref', () => { + const state = mkState( + { + a1: mkNation('a1', 'Twin', NS(50, 50, 50, 50), ['x-r1']), + a2: mkNation('a2', 'Twin', NS(50, 50, 50, 50), ['a-r2']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'a-r2': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['Twin', 'Bravo'] }], resolve: [] }, + ctx(state), + ); + // 'Twin' is ambiguous → dropped → only one party remains → dropped + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + assertSoleOwnership(state); + }); + + it('resolves party refs by unique name (not just id) — the stored parties are the resolved ids', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo'] }], resolve: [] }, + ctx(state), + ); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + assertSoleOwnership(state); + }); + + it('resolves the region by unique name when a key manifest is loaded (validRegionKeys + regionNameOf)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + const c: AbilityCtx = { + previous: state, + actingTurn: state.turn, + validRegionKeys: new Set(['x-r1', 'b-r1']), + regionNameOf: (k) => (k === 'x-r1' ? 'The Great Plain' : k === 'b-r1' ? 'B Plain' : undefined), + }; + applyContested( + state, + { open: [{ regionKey: 'the great plain', parties: ['a1', 'b2'] }], resolve: [] }, + c, + ); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + assertSoleOwnership(state); + }); + + it('a region key absent from the loaded manifest is dropped', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + const c: AbilityCtx = { + previous: state, + actingTurn: state.turn, + validRegionKeys: new Set(['x-r1', 'b-r1']), + regionNameOf: (k) => (k === 'x-r1' ? 'The Great Plain' : k === 'b-r1' ? 'B Plain' : undefined), + }; + applyContested( + state, + { open: [{ regionKey: 'nope-r9', parties: ['a1', 'b2'] }], resolve: [] }, + c, + ); + expect(state.contested).toEqual([]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + assertSoleOwnership(state); + }); + + it('an open re-derives the stripped owner’s stats from its remaining regions', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'a-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { + 'x-r1': RS(10, 10, 10, 10), + 'a-r1': RS(30, 40, 50, 60), + 'b-r1': RS(50, 50, 50, 50), + }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + // a1 keeps only a-r1 → its derived stats become that region's (the + // unweighted median of a single value is the value itself) + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.nations.a1.stats).toEqual(NS(30, 40, 50, 60)); + expect(state.regionStats['x-r1']).toBeUndefined(); + assertSoleOwnership(state); + }); + + it('an owner left with zero regions keeps its directly-mutated stats (0-region guard)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(77, 78, 79, 80), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + expect(state.nations.a1.territory).toEqual([]); + expect(state.nations.a1.stats).toEqual(NS(77, 78, 79, 80)); // unchanged + assertSoleOwnership(state); + }); + + it('two open ops on the same key in one turn: only the first takes effect (the key is ownerless, not NML, afterwards)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, + { wars: [mkWar('w1', ['a1', 'b2']), mkWar('w2', ['a1', 'c3'])] }, + ); + applyContested( + state, + { + open: [ + { regionKey: 'x-r1', parties: ['a1', 'b2'] }, + { regionKey: 'x-r1', parties: ['a1', 'c3'] }, + ], + resolve: [], + }, + ctx(state), + ); + // the first op strips x-r1 from a1; the second then sees 0 owners and no + // NML membership → dropped. The key is contested exactly once (first op). + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); + + it('opens two distinct keys in one turn (both added, in op order)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'x-r2']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { + 'x-r1': RS(10, 10, 10, 10), + 'x-r2': RS(20, 20, 20, 20), + 'b-r1': RS(50, 50, 50, 50), + }, + { wars: [mkWar('w1', ['a1', 'b2'])] }, + ); + applyContested( + state, + { + open: [ + { regionKey: 'x-r1', parties: ['a1', 'b2'] }, + { regionKey: 'x-r2', parties: ['a1', 'b2'] }, + ], + resolve: [], + }, + ctx(state), + ); + expect(state.nations.a1.territory).toEqual([]); + expect(state.contested).toEqual([ + { regionKey: 'x-r1', parties: ['a1', 'b2'] }, + { regionKey: 'x-r2', parties: ['a1', 'b2'] }, + ]); + assertSoleOwnership(state); + }); +}); + +// --------------------------------------------------------------------------- +// applyContested — resolve +// --------------------------------------------------------------------------- + +describe('applyContested — resolve', () => { + it('releases a still-contested (ownerless) key to no man’s land: out of `contested`, into `unowned`, stats deleted', () => { + 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), + // defensive: a stray entry on the ownerless contested key + 'x-r1': RS(10, 10, 10, 10), + }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + applyContested( + state, + { open: [], resolve: ['x-r1'] }, + ctx(state), + ); + expect(state.contested).toEqual([]); + expect(state.unowned).toEqual(['x-r1']); + expect(state.regionStats['x-r1']).toBeUndefined(); + assertSoleOwnership(state); + }); + + it('a resolving party’s concurrent winning claim: the key stays owned, keeps its stats, and is NOT re-added to `contested`', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(60, 55, 50, 45), ['b-r1', 'x-r1']), // re-owned this turn + }, + { + 'a-r1': RS(50, 50, 50, 50), + 'b-r1': RS(60, 55, 50, 45), + // the claim re-initialised the (previously deleted) entry + 'x-r1': RS(10, 20, 30, 40), + }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); + expect(state.contested).toEqual([]); + expect(state.nations.b2.territory).toEqual(['b-r1', 'x-r1']); // still owned + expect(state.unowned).toEqual([]); // NOT released + expect(state.regionStats['x-r1']).toEqual(RS(10, 20, 30, 40)); // kept + assertSoleOwnership(state); + }); + + it('a resolving party’s claim that left no regionStats entry re-inits it from the owner’s stats', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(60, 55, 50, 45), ['b-r1', 'x-r1']), + }, + { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(60, 55, 50, 45) }, // no 'x-r1' entry + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); + expect(state.contested).toEqual([]); + expect(state.nations.b2.territory).toEqual(['b-r1', 'x-r1']); + expect(state.regionStats['x-r1']).toEqual(RS(60, 55, 50, 45)); // re-init = copy of b2.stats + assertSoleOwnership(state); + }); + + it('a resolved key owned by a NON-party (defensive) is released to no man’s land', () => { + 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']), + c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1', 'x-r1']), + }, + { + 'a-r1': RS(50, 50, 50, 50), + 'b-r1': RS(50, 50, 50, 50), + 'c-r1': RS(50, 50, 50, 50), + 'x-r1': RS(10, 10, 10, 10), + }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + // c3 (not a party) somehow owns the contested key → release + applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); + expect(state.contested).toEqual([]); + expect(state.nations.c3.territory).toEqual(['c-r1']); + expect(state.unowned).toEqual(['x-r1']); + expect(state.regionStats['x-r1']).toBeUndefined(); + assertSoleOwnership(state); + }); + + it('resolving one of several contested keys leaves the others untouched', () => { + 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), + 'x-r1': RS(10, 10, 10, 10), + 'x-r2': RS(20, 20, 20, 20), + }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }, { regionKey: 'x-r2', parties: ['a1', 'b2'] }] }, + ); + applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); + expect(state.contested).toEqual([{ regionKey: 'x-r2', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual(['x-r1']); + expect(state.regionStats['x-r2']).toEqual(RS(20, 20, 20, 20)); // untouched + assertSoleOwnership(state); + }); + + it('a resolve of a key that is not currently contested is a no-op', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['x-r2']), + }, + { 'x-r1': RS(10, 10, 10, 10), 'x-r2': RS(20, 20, 20, 20) }, + { contested: [{ regionKey: 'x-r3', parties: ['a1', 'b2'] }] }, + ); + applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); + // x-r1 is not contested → untouched; the x-r3 frontline persists + expect(state.contested).toEqual([{ regionKey: 'x-r3', parties: ['a1', 'b2'] }]); + expect(state.nations.a1.territory).toEqual(['x-r1']); + expect(state.regionStats['x-r1']).toEqual(RS(10, 10, 10, 10)); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); + + it('an empty resolve list keeps every existing contested entry', () => { + 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) }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + applyContested(state, { open: [], resolve: [] }, ctx(state)); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); +}); + +// --------------------------------------------------------------------------- +// applyContested — ordering & interaction (resolve runs before open) +// --------------------------------------------------------------------------- + +describe('applyContested — ordering & interaction', () => { + it('a re-open (open op on a key that already has a contested entry) replaces the entry — unique by region key', () => { + const state = mkState( + { + // a1 re-owns x-r1 this turn (simulating a claim that ran before the + // abilities loop), so the key is contestable again + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1', 'x-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), + }, + { + 'a-r1': RS(50, 50, 50, 50), + 'b-r1': RS(50, 50, 50, 50), + 'c-r1': RS(50, 50, 50, 50), + 'x-r1': RS(10, 10, 10, 10), + }, + { + wars: [mkWar('w1', ['a1', 'b2']), mkWar('w2', ['a1', 'c3'])], + contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], + }, + ); + // a1 is at war with c3 → the new op's war-linkage holds and the open + // replaces the kept entry for the same key + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'c3'] }], resolve: [] }, + ctx(state), + ); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'c3'] }]); + assertSoleOwnership(state); + }); + + it('resolve-then-open on the same key: the release to no man’s land makes a same-turn open from NML succeed', () => { + 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) }, + { + wars: [mkWar('w1', ['a1', 'b2'])], + contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], + }, + ); + // resolve releases x-r1 to NML first; the open then finds it ownerless + + // in unowned with warring parties → it re-contests from no man's land + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r1'] }, + ctx(state), + ); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); + + it('a still-contested key (ownerless, NOT in unowned) cannot be re-opened directly', () => { + 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) }, + { + wars: [mkWar('w1', ['a1', 'b2'])], + contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], + }, + ); + // x-r1 is ownerless and not in unowned → neither open branch applies + applyContested( + state, + { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, + ctx(state), + ); + // the existing entry persists unchanged (a no-op open keeps it) + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); + + it('apply() via the Ability: an open and a resolve in one call; bad refs never throw', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'a-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { + 'x-r1': RS(10, 10, 10, 10), + 'a-r1': RS(50, 50, 50, 50), + 'b-r1': RS(50, 50, 50, 50), + 'x-r2': RS(20, 20, 20, 20), + }, + { + wars: [mkWar('w1', ['a1', 'b2'])], + contested: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], + }, + ); + const ops: ContestedOps = { + open: [ + { regionKey: 'x-r1', parties: ['a1', 'b2'] }, + // an unresolvable second party → only one distinct party → dropped + { regionKey: 'a-r1', parties: ['a1', 'ghost'] }, + ], + resolve: ['x-r2'], + }; + contested.apply(state, ops, ctx(state)); + // x-r1 opened (a1 keeps a-r1); x-r2 released to no man's land; the + // dropped op left a-r1 exactly where it was + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual(['x-r2']); + expect(state.regionStats['x-r1']).toBeUndefined(); + expect(state.regionStats['x-r2']).toBeUndefined(); + assertSoleOwnership(state); + }); + + it('apply() with the default (empty) ops is a no-op on an existing contested list', () => { + 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) }, + { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, + ); + contested.apply(state, contested.default(), ctx(state)); + expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); + expect(state.unowned).toEqual([]); + assertSoleOwnership(state); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object contract +// --------------------------------------------------------------------------- + +describe('contested ability', () => { + it('owns the "contested" field; default() is a fresh empty { open: [], resolve: [] }', () => { + expect(contested.field).toBe('contested'); + expect(contested.default()).toEqual({ open: [], resolve: [] }); + expect(contested.default()).not.toBe(contested.default()); + }); + + it('default() is not meaningful; a non-empty open OR resolve is', () => { + expect(contested.isMeaningful(contested.default())).toBe(false); + expect(contested.isMeaningful({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] })).toBe(true); + expect(contested.isMeaningful({ open: [], resolve: ['x-r1'] })).toBe(true); + }); + + it('validate() wraps the validator into the ContestedOps shape', () => { + expect( + contested.validate({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r2'] }), + ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r2'] }); + expect(contested.validate(undefined)).toEqual({ open: [], resolve: [] }); + }); + + it('the stub value equals default() (the stub never opens or resolves a frontline)', () => { + expect(contested.stub()).toEqual({ open: [], resolve: [] }); + }); + + it('stateFields is ["contested"] (the durable GameState field)', () => { + expect(contested.stateFields).toEqual(['contested']); + }); + + it('schemaLine() / constraint() document the field for the system prompt', () => { + expect(contested.schemaLine()).toContain('"contested"'); + expect(contested.schemaLine()).toContain('"regionKey"'); + expect(contested.constraint()).toContain('frontlines'); + expect(contested.constraint()).toContain('no man'); + }); + + it('payload() contributes nothing when the contested list is empty / undefined (a fresh world)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + expect(contested.payload({ state } as UserPromptCtx)).toEqual({}); + expect(contested.payload({ state, contested: [] } as UserPromptCtx)).toEqual({}); + }); + + it('payload() lists the current frontlines with party NAMES (ids resolved to names)', () => { + 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) }, + ); + expect( + contested.payload( + { state, contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] } as UserPromptCtx, + ), + ).toEqual({ contested: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo'] }] }); + }); + + it('payload() degrades a missing party id (a removed nation) to the raw id, never crashes', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + expect( + contested.payload( + { state, contested: [{ regionKey: 'x-r1', parties: ['ghost', 'a1'] }] } as UserPromptCtx, + ), + ).toEqual({ contested: [{ regionKey: 'x-r1', parties: ['ghost', 'Alpha'] }] }); + }); +}); diff --git a/server/abilities/contested.ts b/server/abilities/contested.ts new file mode 100644 index 0000000..1c205df --- /dev/null +++ b/server/abilities/contested.ts @@ -0,0 +1,301 @@ +// The CONTESTED (contested regions / frontlines) ability (v0.44.0, #66) as a +// modular LLM-ability (v0.40.0, #67) — a THIRD region state, besides "owned by +// one nation" and "no man's land": a war frontline that is in **no** nation's +// `territory` and not in `unowned`, contested by two or more nations at war. +// +// Each turn the LLM may `open` a region as a contested frontline and/or +// `resolve` an existing one. `open` requires the region to have a SOLE owner +// that is at war with at least one of the parties, or to be no man's land (in +// `unowned`) with two or more of the parties at war with each other; a region +// with two or more owners, or whose owner is not at war with any party, cannot +// be contested (dropped). On a successful open the key is removed from its +// owner's `territory` (sole ownership is preserved — a contested key is in NO +// territory) and from `unowned`, its `regionStats` entry is deleted, and a +// `{ regionKey, parties }` entry (parties = resolved nation ids) is added to +// `GameState.contested` (unique by `regionKey` — re-opening replaces it). +// `resolve` removes the entry and releases the key to no man's land — UNLESS a +// concurrent winning claim by one of the parties (allowed by the +// territory-step guard in engine.ts) already re-owned it, in which case it +// stays owned and its stats are kept. +// +// A still-contested key is NOT a valid normal-claim target: a plain `gain` on +// it is dropped in the territory step (only a resolving party's claim +// re-owns it). The layer NEVER moves a key into two territories (sole +// ownership is sacrosanct), never touches borders directly (the key simply +// leaves ownership), and is deterministic (no hidden randomness — the LLM +// declares, the engine applies fixed rules). +// +// This is a DURABLE ability (unlike the turn-transient hybrid / disasters / +// migration layers): it owns the `GameState.contested` field, and the user +// payload carries the current contested list (only when non-empty). + +import { deriveNationStats } from '../../shared/protocol'; +import type { ContestedOps, ContestedRegion } from '../../shared/protocol'; +// The map-fill constants + tooltip label live in the shared protocol module +// (like the sea-zone / no-man's-land constants); re-exported here so the +// index / engine re-export surface is one consistent place. +export { + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, +} from '../../shared/protocol'; +import type { GameState } from '../../shared/types'; +import { atWar } from './apply-helpers'; +import { resolveNationRef, resolveRegionKey, str } from './shared'; +import type { Ability, AbilityCtx } from './types'; + +/** + * (v0.44.0, #66) The SOLE owners of a region key in `next` (a key that is in no + * nation's `territory` yields `[]`; a corrupt 2+ owner key yields both). Pure. + */ +function currentOwners(next: GameState, key: string): string[] { + const owners: string[] = []; + for (const n of Object.values(next.nations)) { + if (n.territory.includes(key)) owners.push(n.id); + } + return owners; +} + +/** + * (v0.44.0, #66) Resolve a region reference (a raw key or a human name) to a + * concrete region key: exact key first, then a unique case-insensitive name + * match. With no region-key manifest loaded (`validKeys` undefined) the raw + * string is accepted as the key (a name cannot be resolved without the + * manifest). Returns `null` when the reference is empty or unresolvable. + */ +function resolveKey( + ref: string, + validKeys?: ReadonlySet, + nameOf?: (key: string) => string | undefined, +): string | null { + const r = (ref ?? '').trim(); + if (!r) return null; + if (!validKeys) return r; // no manifest: accept the raw key as-is + return resolveRegionKey(r, validKeys, nameOf ?? (() => undefined)); +} + +/** + * (v0.44.0, #66) Resolve a list of party references to distinct nation ids + * (id first, then a case-insensitive unique name), preserving first-appearance + * order. An unresolvable ref is dropped. Pure. + */ +function resolveParties(next: GameState, refs: string[]): string[] { + const out: string[] = []; + for (const ref of refs) { + const id = resolveNationRef(next, ref); + if (id && !out.includes(id)) out.push(id); + } + return out; +} + +/** + * (v0.44.0, #66) Re-derive a nation's aggregate `stats` from its regions' + * per-region stats, with the same 0-region / no-entry guards as the engine's + * main recompute (a nation with no region, or none with a stats entry, keeps + * its directly-mutated `stats`). Used after a contested open strips a region + * from its owner. Never throws. + */ +function rederiveOwnerStats( + next: GameState, + ownerId: string, + geoOf?: (key: string) => { area: number; population: number } | undefined, +): void { + const n = next.nations[ownerId]; + if (!n) return; + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } +} + +/** + * (v0.44.0, #66) Validate the raw `contested` field of a turn into a safe + * {@link ContestedOps}. Defensive like the rest of the parser — never throws. + * `open`: each op needs a non-empty `regionKey` (capped at 120 chars) and + * TWO OR MORE non-empty `parties` refs (each capped at 200 chars); an op with + * a missing `regionKey` or fewer than two party refs is dropped. `resolve`: + * each entry is a non-empty region key (capped at 120 chars); empty / non- + * string entries are dropped. Both region keys and party refs are kept RAW + * here (like the hybrid / economy / industry refs) — whether a ref resolves to + * a real region / nation, and whether the war-linkage / ownership conditions + * hold, is checked at apply time (a ref that does not resolve, or a region that + * is not contestable, is dropped there). A missing / malformed top-level object + * yields `{ open: [], resolve: [] }`. + */ +export function validateContestedOps(raw: unknown): ContestedOps { + const out: ContestedOps = { open: [], resolve: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + if (Array.isArray(o.open)) { + for (const e of o.open) { + if (!e || typeof e !== 'object') continue; + const p = e as Record; + const regionKey = str(p.regionKey, 120); + if (!regionKey) continue; // missing / empty region key: drop + if (!Array.isArray(p.parties)) continue; // missing / malformed parties: drop + const parties = p.parties + .map((x) => str(x, 200)) + .filter((x): x is string => x !== null); + if (parties.length < 2) continue; // need at least two party refs + out.open.push({ regionKey, parties }); + } + } + if (Array.isArray(o.resolve)) { + for (const e of o.resolve) { + const key = str(e, 120); + if (key) out.resolve.push(key); + } + } + return out; +} + +/** + * (v0.44.0, #66) Apply the turn's contested-region ops to `next`, mutating in + * place and never throwing. Runs in the `ABILITIES` loop AFTER the territory + * step and the derived-stats recompute, and BEFORE markers / nationOps, so the + * owner / unowned / wars it inspects are all final for this turn. + * + * (1) RESOLVE (first): each key in `resolve` that is currently contested is + * removed from `next.contested` and released to no man's land (into + * `unowned`, out of any territory, `regionStats` deleted) — UNLESS a + * concurrent winning claim by one of the parties (the only claim the + * territory step permits on a contested key) already re-owned it, in + * which case it stays owned and keeps (or re-inits) its stats. + * (2) OPEN: each op that resolves to a contestable region (a sole owner at + * war with a party, or a no-man's-land region whose parties are at war + * with each other) is moved out of ownership (the owner's territory / + * `unowned`), its `regionStats` is deleted, and it is added to + * `next.contested` (unique by `regionKey` — an open replaces a kept + * entry for the same key). The stripped owner's derived stats are + * re-derived. + * + * A contested key is never placed into two territories and is never left in + * `unowned` (the two states are mutually exclusive), so the sole-ownership + * invariant holds after every op. + */ +export function applyContested(next: GameState, ops: ContestedOps, ctx: AbilityCtx): void { + const validKeys = ctx.validRegionKeys; + const nameOf = ctx.regionNameOf; + const geoOf = ctx.geoOf; + const open = Array.isArray(ops?.open) ? ops.open : []; + const resolve = Array.isArray(ops?.resolve) ? ops.resolve : []; + const prevContested = Array.isArray(next.contested) ? next.contested : []; + + // (1) RESOLVE first: rebuild the kept list, dropping resolved keys. A + // resolved key is released to no man's land unless a party re-owned it. + const resolveSet = new Set(resolve); + const kept: ContestedRegion[] = []; + for (const c of prevContested) { + if (!resolveSet.has(c.regionKey)) { + kept.push(c); // not resolved this turn: keep + continue; + } + const owners = currentOwners(next, c.regionKey); + if (owners.length === 1 && c.parties.includes(owners[0])) { + // A party re-owned it via a concurrent winning claim (the territory step + // allowed it): keep it owned, ensure it has stats (the claim re-inits an + // absent entry; the post-claim recompute already reflected the gain). + if (!next.regionStats[c.regionKey]) { + next.regionStats[c.regionKey] = { ...next.nations[owners[0]].stats }; + } + continue; // stays owned — not re-added to `contested` + } + // Release to no man's land: out of any territory (defensive — a contested + // key is normally in none), into `unowned`, and its per-region stats leave. + for (const n of Object.values(next.nations)) { + const i = n.territory.indexOf(c.regionKey); + if (i !== -1) n.territory.splice(i, 1); + } + if (!next.unowned.includes(c.regionKey)) next.unowned.push(c.regionKey); + delete next.regionStats[c.regionKey]; + } + + // (2) OPEN: each op that resolves to a contestable region is moved out of + // ownership and added to the contested list (unique by region key). + const opened = new Map(); // region key -> resolved party ids + for (const op of open) { + const key = resolveKey(op.regionKey, validKeys, nameOf); + if (!key) continue; + const parties = resolveParties(next, op.parties); + if (parties.length < 2) continue; // fewer than two distinct resolved parties + + const owners = currentOwners(next, key); + let ok = false; + if (owners.length === 1) { + // Sole owner present: the owner must be at war with at least one party + // (the owner may itself be a party — atWar is false for a==b, so this + // checks the owner against the OTHER parties). + ok = parties.some((p) => atWar(next.wars, owners[0], p)); + } else if (owners.length === 0 && next.unowned.includes(key)) { + // No owner + no man's land: two of the parties must be at war with + // each other. + for (let i = 0; i < parties.length && !ok; i++) { + for (let j = i + 1; j < parties.length; j++) { + if (atWar(next.wars, parties[i], parties[j])) { + ok = true; + break; + } + } + } + } + // Two or more owners (corrupt), an owner not at war with any party, or a + // not-in-unowned keyless region: never contestable (dropped). + if (!ok) continue; + + if (owners.length === 1) { + const n = next.nations[owners[0]]; + const i = n.territory.indexOf(key); + if (i !== -1) n.territory.splice(i, 1); + rederiveOwnerStats(next, owners[0], geoOf); + } else { + const i = next.unowned.indexOf(key); + if (i !== -1) next.unowned.splice(i, 1); + } + delete next.regionStats[key]; + opened.set(key, parties); + } + + // Rebuild `next.contested`: the kept (unresolved) entries plus the newly + // opened ones, unique by region key (an open replaces a kept entry for the + // same key; a resolve removed its key from `kept` above). + const final: ContestedRegion[] = []; + const seen = new Set(); + for (const c of kept) { + if (opened.has(c.regionKey)) continue; // replaced by a re-open this turn + if (seen.has(c.regionKey)) continue; // dedupe (should not happen, but safe) + seen.add(c.regionKey); + final.push(c); + } + for (const [key, parties] of opened) { + if (seen.has(key)) continue; + seen.add(key); + final.push({ regionKey: key, parties }); + } + next.contested = final; +} + +export const contested: Ability<'contested'> = { + field: 'contested', + default: () => ({ open: [], resolve: [] }), + validate: (raw) => validateContestedOps(raw), + apply: (state, data, ctx) => applyContested(state, data, ctx), + schemaLine: () => + ' "contested": {"open":[{"regionKey":"","parties":["",""]}],"resolve":[""]},', + constraint: () => + '- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man\'s land, contested by two or more nations at war. An "open" op ({"regionKey":"","parties":["",""]}) makes a region a frontline — it leaves its owner\'s territory (or no-man\'s-land) and is contested: the region\'s sole owner must be at war with at least one of the parties, or (for a no-man\'s-land region) two of the parties must be at war with each other. A "resolve" op ([""]) ends a frontline, releasing the region to no man\'s land — unless a party\'s concurrent claim re-owns it. A contested region is in NO nation\'s territory (never two) and is never no man\'s land while contested. The user message\'s "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event).', + isMeaningful: (data) => data.open.length > 0 || data.resolve.length > 0, + stateFields: ['contested'], + stub: () => ({ open: [], resolve: [] }), + // The user payload carries the current contested frontlines (region key + + // party NAMES, resolved from the stored ids) — but only when non-empty (a + // fresh / no-frontline world contributes nothing, like the turn-transient + // layers). + payload: (ctx) => { + if (!ctx.contested || ctx.contested.length === 0) return {}; + return { + contested: ctx.contested.map((c) => ({ + regionKey: c.regionKey, + parties: c.parties.map((id) => ctx.state.nations[id]?.name ?? id), + })), + }; + }, +}; diff --git a/server/abilities/disasters.test.ts b/server/abilities/disasters.test.ts index 32cae23..e9ba795 100644 --- a/server/abilities/disasters.test.ts +++ b/server/abilities/disasters.test.ts @@ -72,6 +72,8 @@ function mkState( industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } diff --git a/server/abilities/goldens/system-no-tool.txt b/server/abilities/goldens/system-no-tool.txt index 35d0144..6ff191d 100644 --- a/server/abilities/goldens/system-no-tool.txt +++ b/server/abilities/goldens/system-no-tool.txt @@ -42,6 +42,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "sea": {"control":[{"zone":"","by":""}]}, + "contested": {"open":[{"regionKey":"","parties":["",""]}],"resolve":[""]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -70,6 +71,7 @@ HARD CONSTRAINTS - 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). +- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"","parties":["",""]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op ([""]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" 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 9e55d0b..53477e0 100644 --- a/server/abilities/goldens/system-tool.txt +++ b/server/abilities/goldens/system-tool.txt @@ -42,6 +42,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "sea": {"control":[{"zone":"","by":""}]}, + "contested": {"open":[{"regionKey":"","parties":["",""]}],"resolve":[""]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -70,6 +71,7 @@ HARD CONSTRAINTS - 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). +- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"","parties":["",""]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op ([""]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" 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 a6fbed1..e378b8f 100644 --- a/server/abilities/goldens/user.txt +++ b/server/abilities/goldens/user.txt @@ -206,5 +206,14 @@ "type": "industry" } ], - "seaZones": [] + "seaZones": [], + "contested": [ + { + "regionKey": "b-r1", + "parties": [ + "Alpha", + "Bravo" + ] + } + ] } \ No newline at end of file diff --git a/server/abilities/index.ts b/server/abilities/index.ts index f9efddd..8973970 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 11 per-feature LLM abilities. +// `ABILITIES` is the single source of truth for the 12 per-feature LLM abilities. // The engine iterates it in this order: -// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea +// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea → contested // 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, @@ -25,10 +25,11 @@ import { ideology } from './ideology'; import { disasters } from './disasters'; import { migration } from './migration'; import { sea } from './sea'; +import { contested } from './contested'; import type { Ability, AbilityField } from './types'; /** - * The 11 per-feature LLM abilities in their canonical apply order. + * The 12 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 @@ -47,6 +48,7 @@ export const ABILITIES: readonly Ability[] = [ disasters, migration, sea, + contested, ]; // ── type re-exports ──────────────────────────────────────────────────────────── @@ -173,3 +175,13 @@ export { isSeaZoneId, seaZoneName, } from './sea'; + +// ── contested (frontlines) ─────────────────────────────────────────────────── + +export { + validateContestedOps, + applyContested, + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, +} from './contested'; diff --git a/server/abilities/migration.test.ts b/server/abilities/migration.test.ts index 3b3c589..b005f74 100644 --- a/server/abilities/migration.test.ts +++ b/server/abilities/migration.test.ts @@ -84,6 +84,8 @@ function mkState( industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts index 5531a7e..b1973fe 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 11 abilities; each ability's default() equals the canonical +// list of the 12 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 @@ -76,6 +76,12 @@ import { SEA_ZONE_IDS, isSeaZoneId, seaZoneName, + // contested (v0.44.0, #66) + validateContestedOps, + applyContested, + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, } from '../engine'; import { createPresetStore } from '../presets'; import { ABILITIES } from './index'; @@ -92,7 +98,7 @@ import type { SeaZoneClaim, TradeAgreement, } from '../../shared/types'; -import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; +import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; const here = path.dirname(fileURLToPath(import.meta.url)); const goldensDir = path.join(here, 'goldens'); @@ -165,6 +171,8 @@ const state: GameState = { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; // --- buildUserPrompt arguments: every optional branch exercised. --- @@ -202,6 +210,10 @@ const nationResources: Record = { const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }]; const nuclear: Record = { a1: true, b2: false, c3: false }; const seaZones: SeaZoneClaim[] = []; +// (v0.44.0, #66) a non-empty contested sample so the golden's user payload +// includes the `contested` key (region key + party NAMES, resolved from the +// state's nations). `b-r1` is owned by b2; the parties are a1 + b2. +const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }]; /** * Assert the current prompt output is byte-identical to the committed golden — @@ -241,6 +253,7 @@ function fullDefaultTurn(): TurnResult { disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, + contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; } @@ -270,11 +283,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] { return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] }; case 'sea': return { control: [{ zone: 'pacific', by: 'a1' }] }; + case 'contested': + return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] }; } } describe('ABILITIES registry (v0.40.0, #67)', () => { - it('is the canonical 11-ability order (takeovers → sea)', () => { + it('is the canonical 12-ability order (takeovers → contested)', () => { expect(ABILITIES.map((a) => a.field)).toEqual([ 'takeovers', 'wars', @@ -287,13 +302,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { 'disasters', 'migration', 'sea', + 'contested', ]); }); - it('lists 11 distinct fields, each a key of TurnResult', () => { + it('lists 12 distinct fields, each a key of TurnResult', () => { const fields = ABILITIES.map((a) => a.field); - expect(fields).toHaveLength(11); - expect(new Set(fields).size).toBe(11); + expect(fields).toHaveLength(12); + expect(new Set(fields).size).toBe(12); const all = fullDefaultTurn(); for (const f of fields) expect(f in all).toBe(true); }); @@ -324,6 +340,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { industryProjects, nuclear, seaZones, + contested, ), ); }); @@ -353,13 +370,13 @@ 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', 'seaZones', 'trades', 'wars']); + expect([...owned].sort()).toEqual(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']); }); it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => { // The registry is a plain ordered array the engine iterates uniformly. A // structural (field-name-agnostic) loop over the abilities — schema line, - // constraint bullet — must keep working when an 11th ability is appended. + // constraint bullet — must keep working when a 12th ability is appended. // This documents the extension point without widening the closed // AbilityField union. interface Loopable { @@ -378,9 +395,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(12); - expect(extended.map((a) => a.constraint())).toHaveLength(12); - expect(extended[11].schemaLine()).toBe(' "custom": [],'); + expect(extended.map((a) => a.schemaLine())).toHaveLength(13); + expect(extended.map((a) => a.constraint())).toHaveLength(13); + expect(extended[12].schemaLine()).toBe(' "custom": [],'); }); it('the engine still re-exports every name its callers import from it', () => { @@ -439,6 +456,12 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { SEA_ZONE_IDS, isSeaZoneId, seaZoneName, + // contested (v0.44.0, #66) + validateContestedOps, + applyContested, + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, }; 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 index 77dff36..988d24d 100644 --- a/server/abilities/sea.test.ts +++ b/server/abilities/sea.test.ts @@ -85,6 +85,8 @@ function mkState( embargoes: [], industry: [], seaZones, + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } diff --git a/server/abilities/types.ts b/server/abilities/types.ts index b7b8e1f..e552ce2 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -15,14 +15,14 @@ import type { SeaZoneClaim, TradeAgreement, } from '../../shared/types'; -import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; +import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; /** - * The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult` + * The 12 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 eleven abilities. + * / markers) plus, in order, these twelve abilities. */ export type AbilityField = | 'takeovers' @@ -35,7 +35,8 @@ export type AbilityField = | 'ideology' | 'disasters' | 'migration' - | 'sea'; + | 'sea' + | 'contested'; /** * Everything a single ability's `apply` may need from the surrounding turn. @@ -92,6 +93,7 @@ export interface UserPromptCtx { readonly nationResources?: Record; readonly industryProjects?: IndustryProject[]; readonly seaZones?: SeaZoneClaim[]; + readonly contested?: ContestedRegion[]; } /** diff --git a/server/db.test.ts b/server/db.test.ts index a77bb41..6068f5d 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -40,6 +40,8 @@ function makeState(id: string): GameState { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } @@ -257,6 +259,17 @@ describe('openDb', () => { db.close(); }); + it('(v0.44.0 #66) a legacy row saved before the contested field existed loads with it defaulted to []', () => { + const db = openDb(':memory:'); + const legacy = makeState('legacy'); + delete (legacy as { contested?: unknown[] }).contested; // a pre-v0.44 save (field absent) + db.save(legacy); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.contested).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 958ad75..a51f8aa 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -135,6 +135,8 @@ function baseState(): GameState { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } @@ -572,7 +574,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -594,7 +596,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -615,7 +617,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -634,7 +636,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1091,7 +1093,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1108,7 +1110,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1131,7 +1133,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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 @@ -1157,7 +1159,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1175,7 +1177,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1265,7 +1267,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1380,7 +1382,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1687,9 +1689,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: [] }, 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); + 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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)', () => { @@ -1702,7 +1704,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1723,7 +1725,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); @@ -1744,7 +1746,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1767,7 +1769,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1800,7 +1802,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.playerNationId).toBe('uk'); // player still leads it @@ -1824,7 +1826,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no nation was renamed @@ -1851,7 +1853,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // neither was renamed (ambiguous) @@ -1877,7 +1879,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); const next = applyTurnResult(state, validated!); @@ -1903,7 +1905,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.uk.name).toBe('The Commonwealth'); @@ -1927,7 +1929,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.version).toBe(2); // exactly one bump for the whole turn @@ -1949,7 +1951,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1983,7 +1985,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: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); @@ -2007,13 +2009,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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 @@ -2040,7 +2042,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: [] }, sea: { control: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped @@ -2087,7 +2089,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: [] }, sea: { control: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, @@ -2112,7 +2114,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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 @@ -2128,14 +2130,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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, sea: { control: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2205,7 +2207,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: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), @@ -2282,6 +2284,8 @@ function mkTurnState( industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; } @@ -2306,6 +2310,7 @@ function mkTurnResult(partial: Partial): TurnResult { disasters: partial.disasters ?? { events: [] }, migration: partial.migration ?? { ops: [] }, sea: partial.sea ?? { control: [] }, + contested: partial.contested ?? { open: [], resolve: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3031,7 +3036,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); // the valid one survives, the empty-name one is dropped @@ -3419,7 +3424,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3440,7 +3445,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3461,7 +3466,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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); }); diff --git a/server/engine.ts b/server/engine.ts index 8ec1831..dc85ff5 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -35,6 +35,7 @@ import { markerId, MAX_MARKERS, nationForPrompt, + type ContestedRegion, type MarkerInfo, type MarkerSpec, type NationOp, @@ -113,6 +114,11 @@ export { SEA_ZONE_IDS, isSeaZoneId, seaZoneName, + validateContestedOps, + applyContested, + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, } from './abilities'; // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- @@ -393,6 +399,14 @@ export function buildUserPrompt( * `TurnResult.sea`. Omitted entirely when undefined. */ seaZones?: SeaZoneClaim[], + /** + * (v0.44.0, #66) The game's current contested regions (each a `{ regionKey, + * parties }` pair — a war frontline in no nation's territory and not in + * `unowned`). Emitted verbatim (even when empty) so the model knows which + * regions are frontlines (and can `resolve` them or see their parties); + * omitted entirely when undefined. + */ + contested?: ContestedRegion[], ): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; @@ -503,6 +517,7 @@ export function buildUserPrompt( nationResources, industryProjects, seaZones, + contested, }; Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks if (noMansLand !== undefined) payload.unowned = noMansLand; @@ -512,6 +527,7 @@ export function buildUserPrompt( 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) + Object.assign(payload, ABILITIES[11].payload(upCtx)); // contested → contested (only when non-empty) return JSON.stringify(payload, null, 2); } @@ -653,6 +669,7 @@ export function validateTurnResult( disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, + contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, }; @@ -940,6 +957,9 @@ export function applyTurnResult( // (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 ?? []; + // (v0.44.0, #66) contested regions (war frontlines); a legacy/corrupt clone + // without the field starts empty (the turn then opens/resolves contests). + next.contested = next.contested ?? []; 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); @@ -1035,12 +1055,34 @@ export function applyTurnResult( } set.add(gainer); } + // (v0.44.0, #66) Contested-region guard for the claims step below: a region + // that is currently a war frontline (in `state.contested`) sits in no + // nation's territory, so a normal `gain` would otherwise treat it as a free + // re-claim. A contested key may only be claimed by a normal claim when this + // same turn resolves the contest AND the gainer is one of the contesting + // parties (a party re-claim); otherwise the frontline stays contested for + // the contested layer (later in the turn) to decide its fate. + const contestedBefore = new Set((state.contested ?? []).map((c) => c.regionKey)); + const resolveThisTurn = new Set(result.contested.resolve); + const contestedParties = new Map( + (state.contested ?? []).map((c) => [c.regionKey, c.parties]), + ); for (const [key, claiming] of claimants) { if (claiming.size !== 1) continue; // double claim: the key stays as it was const gainer = [...claiming][0]; const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt) if (owner === '') continue; // pre-existing double ownership: never touched if (owner !== undefined && owner === gainer) continue; // self-gain: no-op + // (v0.44.0, #66) a currently-contested frontline is not a normal claim + // target: a claim on it only proceeds when this turn resolves the contest + // and the gainer is one of the contesting parties (a party re-claim); + // otherwise the frontline stays contested for the contested layer below. + if ( + contestedBefore.has(key) && + (!resolveThisTurn.has(key) || !contestedParties.get(key)?.includes(gainer)) + ) { + continue; + } // (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a // different sole owner) only proceeds when the attacker's derived military // is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A @@ -1620,6 +1662,10 @@ export async function runTurn(opts: { // (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 ?? [], + // (v0.44.0, #66) the current contested regions (war frontlines) — emitted + // verbatim (even when empty) so the model knows which regions are + // frontlines and can resolve them. + state.contested ?? [], ); 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 f44964d..d3ac632 100644 --- a/server/index.ts +++ b/server/index.ts @@ -192,6 +192,8 @@ app.post('/api/games', (req: Request, res) => { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; // 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 c53dc18..b250386 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -61,6 +61,8 @@ const state: GameState = { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; // 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 870e5f2..ce13377 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -50,6 +50,8 @@ function mkState( industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; 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 4f06b76..eb76a62 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -120,6 +120,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; // 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 e3f8e6e..1b29539 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -108,6 +108,13 @@ export interface StubPayload { * deterministicTurn), so the sea path is covered by unit tests only. */ seaZones?: Array<{ zone: string; by: string }>; + /** + * (v0.44.0, #66) The currently contested regions (war frontlines, + * `{ regionKey, parties }`), emitted by the engine's user prompt. The stub + * ignores this field — it never opens or resolves a contest (see + * deterministicTurn), so the contested path is covered by unit tests only. + */ + contested?: Array<{ regionKey: string; parties: string[] }>; } function truncate(s: string, n: number): string { @@ -193,6 +200,11 @@ function truncate(s: string, n: number): string { * (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. + * + * (v0.44.0, #66) The stub likewise NEVER opens or resolves a contested region + * (a war frontline) — it always returns `contested: { open: [], resolve: [] }` + * — the contested path is covered by unit tests only, keeping the e2e + * assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -272,13 +284,14 @@ export function deterministicTurn(p: StubPayload): TurnResult { nationOps: [], // the stub never proposes nation ops (see doc comment) }; - // The 11 ability fields, filled by the registry in its canonical order. The + // The 12 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 → migration → sea), which must stay byte-identical. + // ideology → disasters → migration → sea → contested), 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 74af422..86d2189 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -783,6 +783,53 @@ export interface SeaOps { control: SeaControlOp[]; } +/** + * (v0.44.0, #66) A single `open` op: make a region **contested** — a war + * frontline that is in **no** nation's `territory` and not in `unowned` (a + * third region state, besides "owned by one nation" and "no man's land"). + * `regionKey` is the region key (or a resolvable region name); `parties` are + * two or more nation refs (ids or names), resolved via the shared + * `resolveNationRef` — the stored `ContestedRegion.parties` are the resolved + * nation ids. + */ +export interface ContestOp { + /** The region key to make contested (the model names it by key or name). */ + regionKey: string; + /** + * The nations contesting the region — two or more refs (ids or names). At + * least two distinct, resolvable nations are required, and the region's + * sole owner (if any) must be at war with one of them (or, for a + * no-man's-land region, two of the parties must be at war with each other). + */ + parties: string[]; +} + +/** + * The contested-region block of a `TurnResult`. Required; default + * `{ open: [], resolve: [] }`. `open` makes a region a contested frontline + * (it leaves its owner's `territory` and `unowned` — sole ownership is + * preserved); `resolve` ends a contest, releasing the region to no man's + * land — unless a concurrent winning claim by one of the parties re-owns it + * (re-initialising its `regionStats`). + */ +export interface ContestedOps { + /** Regions to make contested this turn. */ + open: ContestOp[]; + /** Region keys to resolve (release to no man's land) this turn. */ + resolve: string[]; +} + +/** + * A contested region — a region key that is a war frontline: in **no** + * nation's `territory` and not in `unowned`, contested by `parties` (resolved + * nation ids, two or more). Stored in `GameState.contested` (unique by + * `regionKey`). + */ +export interface ContestedRegion { + regionKey: string; + parties: string[]; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -992,6 +1039,19 @@ export interface TurnResult { * `{ control: [] }`. */ sea: SeaOps; + /** + * (v0.44.0, #66) Contested regions / frontlines: `open` makes a region a + * contested war frontline (it leaves its owner's `territory` and `unowned` + * — a third region state in no territory and not no man's land — and is + * contested by two or more nations at war); `resolve` ends a contest, + * releasing the region to no man's land unless a concurrent winning claim + * by one of the parties re-owns it. A region with a sole owner not at war + * with any party, or two or more owners, cannot be contested. Sole + * ownership is never broken; a still-contested key is not a valid normal + * claim target. At most 1–2 per turn, each narrated in `events`. Required; + * default `{ open: [], resolve: [] }`. + */ + contested: ContestedOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small @@ -1233,6 +1293,27 @@ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)'; /** The border of an owned region (brighter than unowned). */ export const OWNED_STROKE = 'rgba(255,255,255,0.30)'; +/** + * The distinct, visible fill + stroke for a **contested** region (a war + * frontline — a key that is in no nation's `territory` and not in `unowned`). + * Reddish/orange to read as a "hot" frontline on the dark basemap — clearly + * distinct from a nation color and from the no-man's-land grey. The contested + * state is decided by the client (via `GameState.contested`), never by the + * `RegionMap` (which only knows owned vs absent). + */ +export const CONTESTED_FILL = '#f97316'; +export const CONTESTED_STROKE = 'rgba(255,237,213,0.55)'; + +/** + * The hover-tooltip sub-line for a contested region: a short "Contested: + * A vs B" line naming the contesting parties. A missing party name (a nation + * removed by a merge/split) degrades to a dash, never crashes. + */ +export function contestedTooltipLabel(parties: string[]): string { + const names = parties.map((p) => p || '—'); + return `Contested: ${names.join(' vs ')}`; +} + /** The hover-tooltip sub-line for a region with no owning nation. */ export function unownedTooltipLabel(isNoMansLand: boolean): string { return isNoMansLand ? "No man's land" : 'No owner'; diff --git a/shared/scenario.ts b/shared/scenario.ts index 3a46289..9607537 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -463,6 +463,8 @@ export function worldDraftToGameState( industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], + // (v0.44.0, #66) no contested regions on a fresh state. + contested: [], }; return state; } diff --git a/shared/types.ts b/shared/types.ts index 031cee2..660d35d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -3,7 +3,7 @@ // (The single `import type` below erases at compile time, so no runtime // dependency on protocol.ts is introduced — see the GameState.pendingNationOps // doc comment.) -import type { NationOp } from './protocol'; +import type { NationOp, ContestedRegion } from './protocol'; /** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */ export interface NationStats { @@ -474,6 +474,18 @@ export interface GameState { * server/db.ts) — additive, no data wipe. */ seaZones: SeaZoneClaim[]; + /** + * (v0.44.0, #66) Contested regions / frontlines (see {@link ContestedRegion}): + * one entry per region key that is a war frontline — a key in **no** nation's + * `territory` and not in `unowned`, contested by two or more nations at war. + * A region becomes contested when a turn's `contested.open` succeeds (it + * leaves its owner's `territory` and `unowned` — sole ownership is preserved); + * it is resolved (released to no man's land, or re-claimed by a party) by a + * later `contested.resolve`. At most ONE entry per region key (unique by + * `regionKey`). Always `[]` on a fresh game; rows saved before v0.44 load + * with this defaulted to `[]` (see server/db.ts) — additive, no data wipe. + */ + contested: ContestedRegion[]; } /** diff --git a/src/App.tsx b/src/App.tsx index cab5da4..d59e1a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -834,6 +834,7 @@ function GameScreen({ unowned={game.unowned ?? []} markers={game.markers ?? []} regionStats={game.regionStats ?? {}} + contested={game.contested ?? []} onFocusNation={focusNation} /> diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index d29fb94..eb1328e 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -9,11 +9,15 @@ import 'maplibre-gl/dist/maplibre-gl.css'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; maplibregl.setWorkerUrl(workerUrl); import { + CONTESTED_FILL, + CONTESTED_STROKE, + contestedTooltipLabel, mapFeatureOwner, MARKER_TYPE_INFO, nationColor, regionFillStyle, unownedTooltipLabel, + type ContestedRegion, type RegionMap, type RegionOwner, } from '@shared/protocol'; @@ -146,6 +150,12 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu * no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey * (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint * preset-unassigned look. Both come from the shared `regionFillStyle` contract. + * + * (v0.44.0, #66) A **contested** region (a war frontline — `contestedSet`, from + * `GameState.contested`) takes precedence over both unowned looks: it is in no + * nation's territory and not no man's land, so it renders the distinct + * reddish/orange `CONTESTED_FILL` / `CONTESTED_STROKE`. The state is decided by + * the client, never by the RegionMap (which has no entry for a contested key). */ function applyRegions( geo: FeatureCollection, @@ -153,6 +163,7 @@ function applyRegions( playerNationId: string, idKey: Record, unownedSet: ReadonlySet, + contestedSet: ReadonlySet, ): FeatureCollection { const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); for (const f of out.features) { @@ -165,23 +176,36 @@ function applyRegions( // key the RegionMap by it — never the sub-region id or the repacked id. const key = idKey[subId] ?? ''; f.properties.regionKey = key; - const r = key ? regions[key] : undefined; - if (r) { - const style = regionFillStyle(r, false); - f.properties.fill = style.fill; - f.properties.stroke = style.stroke; - f.properties.owner = r.owner; - f.properties.ownerId = r.ownerId; - f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; - } else { - // Ownerless: visible grey only for keys released mid-game (no man's - // land); a preset that never assigned this sub-region stays faint. - const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key)); - f.properties.fill = style.fill; - f.properties.stroke = style.stroke; + // (v0.44.0, #66) A contested frontline takes precedence over every other + // state (owned, unowned, no man's land): the key is in no nation's + // territory and not no man's land, so it renders the distinct contested + // fill/stroke (decided by the client from `GameState.contested`, never by + // the RegionMap, which has no entry for it). + if (key !== '' && contestedSet.has(key)) { + f.properties.fill = CONTESTED_FILL; + f.properties.stroke = CONTESTED_STROKE; f.properties.owner = ''; f.properties.ownerId = ''; f.properties.isPlayer = 0; + } else { + const r = key ? regions[key] : undefined; + if (r) { + const style = regionFillStyle(r, false); + f.properties.fill = style.fill; + f.properties.stroke = style.stroke; + f.properties.owner = r.owner; + f.properties.ownerId = r.ownerId; + f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; + } else { + // Ownerless: visible grey only for keys released mid-game (no man's + // land); a preset that never assigned this sub-region stays faint. + const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key)); + f.properties.fill = style.fill; + f.properties.stroke = style.stroke; + f.properties.owner = ''; + f.properties.ownerId = ''; + f.properties.isPlayer = 0; + } } } return out; @@ -412,8 +436,14 @@ function hoverHtml( noMansLand = false, regionKey?: string, regionStats?: Record, + contestedParties?: string[], ): string { if (!region || !nation) { + // (v0.44.0, #66) A contested frontline (a region in no nation's territory + // and not no man's land) names its contesting parties in the sub-line. + if (contestedParties && contestedParties.length > 0) { + return `
${esc(subRegionName || 'Contested')}
${contestedTooltipLabel(contestedParties)}
`; + } return `
${esc(subRegionName || 'Unclaimed')}
${unownedTooltipLabel(noMansLand)}
`; } // (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's @@ -479,6 +509,15 @@ interface Props { * stats. */ regionStats?: Record; + /** + * (v0.44.0, #66) The game's contested regions / war frontlines + * (`GameState.contested` — a region key + its contesting party ids). Such a + * region is in no nation's territory and not no man's land (a third region + * state): it renders the distinct reddish/orange `CONTESTED_FILL` and its + * hover tooltip names the parties. The map legend is unaffected (a + * contested key is absent from the RegionMap, so it adds no entry). + */ + contested?: ContestedRegion[]; } /** @@ -536,6 +575,7 @@ export default function WorldMap({ onFocusNation, markers, regionStats, + contested, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -553,6 +593,10 @@ export default function WorldMap({ const markersRef = useRef(markers ?? []); // (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip. const regionStatsRef = useRef>(regionStats ?? {}); + // (v0.44.0, #66) latest contested regions, region key -> contesting party + // NAMES (the stored parties are resolved nation ids, resolved here so the + // hover tooltip can show "Contested: A vs B"). + const contestedRef = useRef>(new Map()); regionsRef.current = regions; nationsRef.current = nations; playerRef.current = playerNationId; @@ -561,6 +605,11 @@ export default function WorldMap({ onFocusNationRef.current = onFocusNation; markersRef.current = markers ?? []; regionStatsRef.current = regionStats ?? {}; + { + const m = new Map(); + for (const c of contested ?? []) m.set(c.regionKey, c.parties.map((id) => nations[id]?.name ?? id)); + contestedRef.current = m; + } // Diffing state for the transient territory-change flash (owned by this component // so a resync with identical data never re-flashes). @@ -721,9 +770,12 @@ export default function WorldMap({ const pKey = f.properties?.regionKey; const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined; const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr); + // (v0.44.0, #66) a contested frontline (in no territory, not no man's + // land) names its contesting parties in the tooltip sub-line. + const contestedParties = !region && keyStr !== undefined ? contestedRef.current.get(keyStr) : undefined; hoverPopup .setLngLat(e.lngLat) - .setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current)) + .setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current, contestedParties)) .addTo(map); map.getCanvas().style.cursor = 'pointer'; }; @@ -799,7 +851,7 @@ export default function WorldMap({ const idKey = idKeyRef.current; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; if (!geo || !idKey || !src) return; - src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current)); + src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current, new Set(contestedRef.current.keys()))); }; // Stamp the current markers onto the marker source (v0.27, #44). @@ -977,7 +1029,7 @@ export default function WorldMap({ if (map && geo && idKey) { const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; - if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet)); + if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet, new Set(contestedRef.current.keys()))); } if (wasPrimed && prev && idKey) { @@ -989,7 +1041,9 @@ export default function WorldMap({ // up as ownerId changes above, so they flash like any other transfer. if (changed.size > 0) flashApiRef.current?.([...changed]); } - }, [regions, playerNationId, unowned]); + // (v0.44.0, #66) re-paint when the contested set changes too — a contested + // key is absent from the RegionMap, so the diff alone wouldn't repaint it. + }, [regions, playerNationId, unowned, contested]); // (v0.27, #44) Repaint the marker symbol layer whenever the marker list // changes (a turn placed/removed markers) or the nations record changes