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
+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();