feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s
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:
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -206,5 +206,14 @@
|
||||
"type": "industry"
|
||||
}
|
||||
],
|
||||
"seaZones": []
|
||||
"seaZones": [],
|
||||
"contested": [
|
||||
{
|
||||
"regionKey": "b-r1",
|
||||
"parties": [
|
||||
"Alpha",
|
||||
"Bravo"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -85,6 +85,8 @@ function mkState(
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
seaZones,
|
||||
// (v0.44.0, #66) no contested regions on a fresh state.
|
||||
contested: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user