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

A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
This commit is contained in:
2026-10-16 15:21:45 +00:00
parent d946fdf0d7
commit 5477336537
27 changed files with 1743 additions and 80 deletions
+81
View File
@@ -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';