// 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), })), }; }, };