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.
302 lines
14 KiB
TypeScript
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),
|
|
})),
|
|
};
|
|
},
|
|
};
|