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
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
// 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),
})),
};
},
};
+2
View File
@@ -72,6 +72,8 @@ function mkState(
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -42,6 +42,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -70,6 +71,7 @@ HARD CONSTRAINTS
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
- 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).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+2
View File
@@ -42,6 +42,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -70,6 +71,7 @@ HARD CONSTRAINTS
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).
- 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).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+10 -1
View File
@@ -206,5 +206,14 @@
"type": "industry"
}
],
"seaZones": []
"seaZones": [],
"contested": [
{
"regionKey": "b-r1",
"parties": [
"Alpha",
"Bravo"
]
}
]
}
+15 -3
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities.
// `ABILITIES` is the single source of truth for the 12 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea → contested
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
@@ -25,10 +25,11 @@ import { ideology } from './ideology';
import { disasters } from './disasters';
import { migration } from './migration';
import { sea } from './sea';
import { contested } from './contested';
import type { Ability, AbilityField } from './types';
/**
* The 11 per-feature LLM abilities in their canonical apply order.
* The 12 per-feature LLM abilities in their canonical apply order.
* The order matters: it determines the order of the schema lines in the
* system prompt, the order of the validate calls, the order of the apply
* steps, and the order of the payload fields in the user prompt. New
@@ -47,6 +48,7 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
disasters,
migration,
sea,
contested,
];
// ── type re-exports ────────────────────────────────────────────────────────────
@@ -173,3 +175,13 @@ export {
isSeaZoneId,
seaZoneName,
} from './sea';
// ── contested (frontlines) ───────────────────────────────────────────────────
export {
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from './contested';
+2
View File
@@ -84,6 +84,8 @@ function mkState(
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
+34 -11
View File
@@ -12,7 +12,7 @@
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 11 abilities; each ability's default() equals the canonical
// list of the 12 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended
@@ -76,6 +76,12 @@ import {
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from '../engine';
import { createPresetStore } from '../presets';
import { ABILITIES } from './index';
@@ -92,7 +98,7 @@ import type {
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
const here = path.dirname(fileURLToPath(import.meta.url));
const goldensDir = path.join(here, 'goldens');
@@ -165,6 +171,8 @@ const state: GameState = {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// --- buildUserPrompt arguments: every optional branch exercised. ---
@@ -202,6 +210,10 @@ const nationResources: Record<string, RegionResources> = {
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
const seaZones: SeaZoneClaim[] = [];
// (v0.44.0, #66) a non-empty contested sample so the golden's user payload
// includes the `contested` key (region key + party NAMES, resolved from the
// state's nations). `b-r1` is owned by b2; the parties are a1 + b2.
const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }];
/**
* Assert the current prompt output is byte-identical to the committed golden —
@@ -241,6 +253,7 @@ function fullDefaultTurn(): TurnResult {
disasters: { events: [] },
migration: { ops: [] },
sea: { control: [] },
contested: { open: [], resolve: [] },
markers: { add: [], remove: [] },
};
}
@@ -270,11 +283,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
case 'sea':
return { control: [{ zone: 'pacific', by: 'a1' }] };
case 'contested':
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 11-ability order (takeovers → sea)', () => {
it('is the canonical 12-ability order (takeovers → contested)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -287,13 +302,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'disasters',
'migration',
'sea',
'contested',
]);
});
it('lists 11 distinct fields, each a key of TurnResult', () => {
it('lists 12 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(11);
expect(new Set(fields).size).toBe(11);
expect(fields).toHaveLength(12);
expect(new Set(fields).size).toBe(12);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -324,6 +340,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
industryProjects,
nuclear,
seaZones,
contested,
),
);
});
@@ -353,13 +370,13 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('declares exactly the durable GameState fields the db migration must default', () => {
const owned: string[] = [];
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']);
expect([...owned].sort()).toEqual(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']);
});
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
// The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when an 11th ability is appended.
// constraint bullet — must keep working when a 12th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
@@ -378,9 +395,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(12);
expect(extended.map((a) => a.constraint())).toHaveLength(12);
expect(extended[11].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(13);
expect(extended.map((a) => a.constraint())).toHaveLength(13);
expect(extended[12].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -439,6 +456,12 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
+2
View File
@@ -85,6 +85,8 @@ function mkState(
embargoes: [],
industry: [],
seaZones,
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
+6 -4
View File
@@ -15,14 +15,14 @@ import type {
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* The 12 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* field each one owns. This is the closed universe the ordered `ABILITIES`
* registry (./index.ts) is built from: a turn's world changes are exactly the
* core engine effects (events / stats / territory / no-man's-land / nation-ops
* / markers) plus, in order, these eleven abilities.
* / markers) plus, in order, these twelve abilities.
*/
export type AbilityField =
| 'takeovers'
@@ -35,7 +35,8 @@ export type AbilityField =
| 'ideology'
| 'disasters'
| 'migration'
| 'sea';
| 'sea'
| 'contested';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
@@ -92,6 +93,7 @@ export interface UserPromptCtx {
readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[];
readonly seaZones?: SeaZoneClaim[];
readonly contested?: ContestedRegion[];
}
/**
+13
View File
@@ -40,6 +40,8 @@ function makeState(id: string): GameState {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -257,6 +259,17 @@ describe('openDb', () => {
db.close();
});
it('(v0.44.0 #66) a legacy row saved before the contested field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { contested?: unknown[] }).contested; // a pre-v0.44 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.contested).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+43 -38
View File
@@ -135,6 +135,8 @@ function baseState(): GameState {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -572,7 +574,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -594,7 +596,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -615,7 +617,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -634,7 +636,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1091,7 +1093,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1108,7 +1110,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1131,7 +1133,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1157,7 +1159,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1175,7 +1177,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1265,7 +1267,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1380,7 +1382,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1687,9 +1689,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1702,7 +1704,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1723,7 +1725,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1744,7 +1746,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1767,7 +1769,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1800,7 +1802,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1824,7 +1826,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1851,7 +1853,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1877,7 +1879,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1903,7 +1905,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1927,7 +1929,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1949,7 +1951,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1983,7 +1985,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2007,13 +2009,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2040,7 +2042,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2087,7 +2089,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2112,7 +2114,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2128,14 +2130,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2205,7 +2207,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2282,6 +2284,8 @@ function mkTurnState(
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -2306,6 +2310,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
disasters: partial.disasters ?? { events: [] },
migration: partial.migration ?? { ops: [] },
sea: partial.sea ?? { control: [] },
contested: partial.contested ?? { open: [], resolve: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3031,7 +3036,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3419,7 +3424,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3440,7 +3445,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3461,7 +3466,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(false);
});
+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.';
+2
View File
@@ -192,6 +192,8 @@ app.post('/api/games', (req: Request, res) => {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
+2
View File
@@ -61,6 +61,8 @@ const state: GameState = {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+2
View File
@@ -50,6 +50,8 @@ function mkState(
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
+2
View File
@@ -120,6 +120,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
+15 -2
View File
@@ -108,6 +108,13 @@ export interface StubPayload {
* deterministicTurn), so the sea path is covered by unit tests only.
*/
seaZones?: Array<{ zone: string; by: string }>;
/**
* (v0.44.0, #66) The currently contested regions (war frontlines,
* `{ regionKey, parties }`), emitted by the engine's user prompt. The stub
* ignores this field — it never opens or resolves a contest (see
* deterministicTurn), so the contested path is covered by unit tests only.
*/
contested?: Array<{ regionKey: string; parties: string[] }>;
}
function truncate(s: string, n: number): string {
@@ -193,6 +200,11 @@ function truncate(s: string, n: number): string {
* (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea
* zone (it always returns `sea: { control: [] }`) — the sea-control path is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.44.0, #66) The stub likewise NEVER opens or resolves a contested region
* (a war frontline) — it always returns `contested: { open: [], resolve: [] }`
* — the contested path is covered by unit tests only, keeping the e2e
* assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -272,13 +284,14 @@ export function deterministicTurn(p: StubPayload): TurnResult {
nationOps: [], // the stub never proposes nation ops (see doc comment)
};
// The 11 ability fields, filled by the registry in its canonical order. The
// The 12 ability fields, filled by the registry in its canonical order. The
// stub is ability-inert: every ability contributes its own no-op default
// (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology → disasters → migration → sea), which must stay byte-identical.
// ideology → disasters → migration → sea → contested), which must stay
// byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();