feat: #65 sea control — 11th ability (v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s

Adds the sea ability: a fixed set of 12 named sea zones with sole-control
semantics, contest-by-higher-military, and a deterministic per-turn
+1 military/+1 economy bonus for each claimed zone. Durable (owns
GameState.seaZones), additive migration, stub-inert (e2e unaffected).

Fixes #65

Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented
approved design is this richer 'sea' layer with a fixed zone set.
This commit is contained in:
2026-10-16 12:50:22 +00:00
parent 13f91845fd
commit c77e81c45b
24 changed files with 1060 additions and 58 deletions
+35 -9
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 10 abilities; each ability's default() equals the canonical
// list of the 11 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
@@ -68,6 +68,14 @@ import {
validateNuclearOps,
validateTakeovers,
validateWarOps,
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
} from '../engine';
import { createPresetStore } from '../presets';
import { ABILITIES } from './index';
@@ -81,6 +89,7 @@ import type {
RegionResources,
RelationStatus,
RoundItem,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
@@ -154,6 +163,8 @@ const state: GameState = {
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
// --- buildUserPrompt arguments: every optional branch exercised. ---
@@ -190,6 +201,7 @@ 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[] = [];
/**
* Assert the current prompt output is byte-identical to the committed golden —
@@ -228,6 +240,7 @@ function fullDefaultTurn(): TurnResult {
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
sea: { control: [] },
markers: { add: [], remove: [] },
};
}
@@ -255,11 +268,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
case 'migration':
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
case 'sea':
return { control: [{ zone: 'pacific', by: 'a1' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 10-ability order (takeovers → migration)', () => {
it('is the canonical 11-ability order (takeovers → sea)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -271,13 +286,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'ideology',
'disasters',
'migration',
'sea',
]);
});
it('lists 10 distinct fields, each a key of TurnResult', () => {
it('lists 11 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(10);
expect(new Set(fields).size).toBe(10);
expect(fields).toHaveLength(11);
expect(new Set(fields).size).toBe(11);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -307,6 +323,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
nationResources,
industryProjects,
nuclear,
seaZones,
),
);
});
@@ -336,7 +353,7 @@ 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', 'trades', 'wars']);
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']);
});
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
@@ -361,9 +378,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(11);
expect(extended.map((a) => a.constraint())).toHaveLength(11);
expect(extended[10].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(12);
expect(extended.map((a) => a.constraint())).toHaveLength(12);
expect(extended[11].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -413,6 +430,15 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
MIGRATION_SOURCE_EFFECTS,
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
// sea (v0.43.0, #65)
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();