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
+46
View File
@@ -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.';