Files
History/server/abilities/contested.ts
T
kamilkrzywda 5477336537
CI / build + unit + e2e (pull_request) Successful in 2m10s
feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
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.
2026-10-16 15:21:45 +00:00

302 lines
14 KiB
TypeScript

// 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<string>,
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<string, unknown>;
if (Array.isArray(o.open)) {
for (const e of o.open) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
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<string, string[]>(); // 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<string>();
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":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},',
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":"<key>","parties":["<a>","<b>"]}) 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 (["<key>"]) 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),
})),
};
},
};