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
+2
View File
@@ -70,6 +70,8 @@ function mkState(
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
@@ -41,6 +41,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"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>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -68,6 +69,7 @@ HARD CONSTRAINTS
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- 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).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+2
View File
@@ -41,6 +41,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"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>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -68,6 +69,7 @@ HARD CONSTRAINTS
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- 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).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+2 -1
View File
@@ -205,5 +205,6 @@
"regionKey": "a-r1",
"type": "industry"
}
]
],
"seaZones": []
}
+18 -3
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 10 per-feature LLM abilities.
// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea
// 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,
@@ -24,10 +24,11 @@ import { leaders } from './leaders';
import { ideology } from './ideology';
import { disasters } from './disasters';
import { migration } from './migration';
import { sea } from './sea';
import type { Ability, AbilityField } from './types';
/**
* The 10 per-feature LLM abilities in their canonical apply order.
* The 11 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
@@ -45,6 +46,7 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
ideology,
disasters,
migration,
sea,
];
// ── type re-exports ────────────────────────────────────────────────────────────
@@ -158,3 +160,16 @@ export {
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
} from './migration';
// ── sea ──────────────────────────────────────────────────────────────────────
export {
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
} from './sea';
+2
View File
@@ -82,6 +82,8 @@ function mkState(
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
+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();
+494
View File
@@ -0,0 +1,494 @@
// Unit tests for the SEA (naval / sea control) ability (v0.43.0, #65) — the
// modular, DURABLE LLM-ability that gives nations sole control over a fixed
// set of 12 named sea zones: the zone is upserted in `GameState.seaZones`
// (replace in place, contest by higher current military), and every committed
// turn the controller earns a deterministic +1 military / +1 economy bonus
// (clamped, mirrored to its regions, re-derived). The layer never touches
// `territory` / `unowned` — it is fully parallel to but independent of land.
// This is the ability's own test file (the registry's cross-cutting
// guarantees — order, distinct fields, the system/user goldens — are covered
// by ./registry.test.ts). The stub never proposes a sea op, so the apply path
// is exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import {
SEA_ZONES,
SEA_ZONE_IDS,
applySeaControl,
applySeaZoneBonuses,
isSeaZoneId,
sea,
seaZoneName,
validateSeaOps,
} from './sea';
import type {
GameState,
Nation,
NationStats,
RegionStats,
SeaZoneClaim,
} from '../../shared/types';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
/** Build a NationStats from the four canonical indices (military, economy, stability, population). */
function NS(military: number, economy: number, stability: number, population: number): NationStats {
return { military, economy, stability, population };
}
/** Build a RegionStats from the four canonical indices. */
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
return { military, economy, stability, population };
}
/** A minimal nation: the given id/name/stats/region-key territory. */
function mkNation(
id: string,
name: string,
stats: NationStats,
territory: string[],
): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
/**
* A minimal GameState carrying the given nations (keyed by id), per-region
* stats, and an optional existing sea-zone list. Every other required field
* is at its fresh-game baseline.
*/
function mkState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
seaZones: SeaZoneClaim[] = [],
): GameState {
return {
id: 'g1',
presetId: 'test',
presetTitle: 'Test',
playerNationId: Object.keys(nations)[0] ?? 'a1',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats,
wars: [],
trades: [],
embargoes: [],
industry: [],
seaZones,
};
}
// ---------------------------------------------------------------------------
// SEA_ZONES / isSeaZoneId / seaZoneName
// ---------------------------------------------------------------------------
describe('SEA_ZONES / SEA_ZONE_IDS / isSeaZoneId / seaZoneName', () => {
it('exposes exactly the 12 fixed sea zones', () => {
expect(SEA_ZONES).toHaveLength(12);
const ids = SEA_ZONES.map((z) => z.id);
expect(ids).toEqual([
'north-atlantic',
'south-atlantic',
'pacific',
'indian',
'mediterranean',
'north-sea',
'baltic',
'arctic',
'black-sea',
'gulf-mexico',
'red-sea',
'sargasso',
]);
});
it('SEA_ZONE_IDS is a ReadonlySet of all 12 ids', () => {
expect(SEA_ZONE_IDS).toBeInstanceOf(Set);
expect(SEA_ZONE_IDS.size).toBe(12);
for (const z of SEA_ZONES) expect(SEA_ZONE_IDS.has(z.id)).toBe(true);
});
it('isSeaZoneId accepts each known id and rejects unknown / non-strings', () => {
for (const z of SEA_ZONES) expect(isSeaZoneId(z.id)).toBe(true);
expect(isSeaZoneId('atlantic-ocean')).toBe(false);
expect(isSeaZoneId('')).toBe(false);
});
it('seaZoneName returns the human-readable name; an unknown id is returned raw', () => {
expect(seaZoneName('pacific')).toBe('Pacific Ocean');
expect(seaZoneName('mediterranean')).toBe('Mediterranean Sea');
expect(seaZoneName('nope')).toBe('nope');
});
});
// ---------------------------------------------------------------------------
// validateSeaOps
// ---------------------------------------------------------------------------
describe('validateSeaOps', () => {
it('accepts the { control: [...] } object shape and a bare array', () => {
const op = { zone: 'pacific', by: 'a1' };
expect(validateSeaOps({ control: [op] })).toEqual({ control: [op] });
expect(validateSeaOps([op])).toEqual({ control: [op] });
});
it('drops an op with an unknown / missing zone', () => {
expect(validateSeaOps([{ zone: 'nope', by: 'a1' }])).toEqual({ control: [] });
expect(validateSeaOps([{ by: 'a1' }])).toEqual({ control: [] });
expect(validateSeaOps([{ zone: '', by: 'a1' }])).toEqual({ control: [] });
});
it('drops an op with an empty / missing `by` (the controller)', () => {
expect(validateSeaOps([{ zone: 'pacific', by: '' }])).toEqual({ control: [] });
expect(validateSeaOps([{ zone: 'pacific' }])).toEqual({ control: [] });
});
it('drops a non-object entry', () => {
expect(validateSeaOps([42, 'nope', null, { zone: 'pacific', by: 'a1' }])).toEqual({
control: [{ zone: 'pacific', by: 'a1' }],
});
});
it('a missing / malformed top-level value yields { control: [] }', () => {
expect(validateSeaOps(undefined)).toEqual({ control: [] });
expect(validateSeaOps(null)).toEqual({ control: [] });
expect(validateSeaOps('nope')).toEqual({ control: [] });
expect(validateSeaOps({ control: 'nope' })).toEqual({ control: [] });
});
it('caps `zone` at 100 and `by` at 200 chars', () => {
const longBy = 'f'.repeat(500);
const out = validateSeaOps([{ zone: 'pacific', by: longBy }]);
expect(out.control).toHaveLength(1);
expect(out.control[0].by).toHaveLength(200);
});
});
// ---------------------------------------------------------------------------
// applySeaControl
// ---------------------------------------------------------------------------
describe('applySeaControl', () => {
it('a single op sets a new zone claim', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'a1' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('replaces an existing claim in place (position preserved)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
[
{ zone: 'pacific', by: 'a1' },
{ zone: 'indian', by: 'b2' },
],
);
// b2 now claims pacific — the pacific claim moves to b2 but stays at index 0
applySeaControl(state, [{ zone: 'pacific', by: 'b2' }]);
expect(state.seaZones).toEqual([
{ zone: 'pacific', by: 'b2' },
{ zone: 'indian', by: 'b2' },
]);
});
it('appends a new zone after existing claims', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaControl(state, [{ zone: 'arctic', by: 'a1' }]);
expect(state.seaZones).toEqual([
{ zone: 'pacific', by: 'a1' },
{ zone: 'arctic', by: 'a1' },
]);
});
it('a contest: the higher current military wins', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
);
// b2 has higher military (90 > 80) → b2 wins
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]);
});
it('a contest tie: the earlier-listed op wins', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']),
},
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) },
);
// equal military (50 == 50) → a1 wins (listed first)
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('two ops for the same nation on the same zone: a single claim (no contest)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'a1' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('resolves `by` by unique name (not just id)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'Alpha' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('an unresolvable `by` is dropped; if all ops on a zone drop, the zone is untouched', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
// ghost does not exist → dropped; pacific stays as a1
applySeaControl(state, [{ zone: 'pacific', by: 'ghost' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
// a NEW zone with only an unresolvable op → zone not added
applySeaControl(state, [{ zone: 'arctic', by: 'ghost' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('an ambiguous name (two nations share it) drops the op', () => {
const state = mkState(
{
a1: mkNation('a1', 'Twin', NS(80, 70, 60, 50), ['a-r1']),
a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), ['a-r2']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'a-r2': RS(20, 20, 20, 20) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'Twin' }]);
// ambiguous → dropped
expect(state.seaZones).toEqual([]);
});
it('never touches `territory` or `unowned`', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
);
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.nations.b2.territory).toEqual(['b-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// applySeaZoneBonuses
// ---------------------------------------------------------------------------
describe('applySeaZoneBonuses', () => {
it('a controller earns +1 military and +1 economy (clamped, mirrored, re-derived)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
// +1 to both military and economy, mirrored to the region, re-derived
// (single region → re-derive gives back the region's value)
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
expect(state.regionStats['a-r1']).toEqual(RS(51, 51, 50, 50));
});
it('clamps to 0 and 100', () => {
// military at 99 → 100; economy at 1 → 2
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(99, 1, 50, 50), ['a-r1']) },
{ 'a-r1': RS(99, 1, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.stats.military).toBe(100);
expect(state.nations.a1.stats.economy).toBe(2);
});
it('a nation controlling several zones stacks the bonus', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[
{ zone: 'pacific', by: 'a1' },
{ zone: 'indian', by: 'a1' },
],
);
applySeaZoneBonuses(state);
// +2 to both (one per zone)
expect(state.nations.a1.stats).toEqual(NS(52, 52, 50, 50));
});
it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), []) },
{},
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
// direct mutation, no re-derive (no regions)
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
});
it('a stale controller (nation no longer in `nations`) is skipped; the claim is kept', () => {
// Build a state where the claim references a nation id that is not in `nations`
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'ghost' }],
);
applySeaZoneBonuses(state);
// ghost is not in nations → skipped; a1 is unaffected
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
// the claim is kept (not removed)
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'ghost' }]);
});
it('an empty seaZones list is a no-op', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
});
it('never touches `territory` or `unowned`', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// The Ability object contract
// ---------------------------------------------------------------------------
describe('sea ability', () => {
it('owns the "sea" field; default() is a fresh empty { control: [] }', () => {
expect(sea.field).toBe('sea');
expect(sea.default()).toEqual({ control: [] });
expect(sea.default()).not.toBe(sea.default());
});
it('default() is not meaningful; a populated value is', () => {
expect(sea.isMeaningful(sea.default())).toBe(false);
expect(sea.isMeaningful({ control: [{ zone: 'pacific', by: 'a1' }] })).toBe(true);
});
it('validate() wraps the validator into the SeaOps shape', () => {
expect(sea.validate([{ zone: 'pacific', by: 'a1' }])).toEqual({
control: [{ zone: 'pacific', by: 'a1' }],
});
expect(sea.validate(undefined)).toEqual({ control: [] });
});
it('the stub value equals default() (the stub never proposes a sea op)', () => {
expect(sea.stub()).toEqual({ control: [] });
});
it('stateFields is [seaZones] (the durable GameState field)', () => {
expect(sea.stateFields).toEqual(['seaZones']);
});
it('payload() emits seaZones (even when empty / undefined)', () => {
expect(sea.payload({ seaZones: [] } as never)).toEqual({ seaZones: [] });
expect(sea.payload({} as never)).toEqual({ seaZones: [] });
expect(sea.payload({ seaZones: [{ zone: 'pacific', by: 'a1' }] } as never)).toEqual({
seaZones: [{ zone: 'pacific', by: 'a1' }],
});
});
it('apply() runs the ops then the per-turn bonus (no throw on a bad ref)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(80, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(80, 40, 20, 30) },
);
const ctx = { previous: state, actingTurn: state.turn };
sea.apply(
state,
{
control: [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
// an unresolvable ref — must be dropped, not throw
{ zone: 'arctic', by: 'ghost' },
],
},
ctx,
);
// contest: b2 (military 80) beats a1 (military 50) → b2 controls pacific
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]);
// b2 gets the +1/+1 bonus (it controls pacific)
expect(state.nations.b2.stats).toEqual(NS(81, 41, 20, 30));
// a1 is unaffected
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
});
it('apply() with an empty control still applies the per-turn bonus to existing claims', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
const ctx = { previous: state, actingTurn: state.turn };
sea.apply(state, { control: [] }, ctx);
// no new claims, but the existing claim's controller earns the bonus
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
});
});
+210
View File
@@ -0,0 +1,210 @@
// The SEA (naval / sea control) ability (v0.43.0, #65) as a modular
// LLM-ability (v0.40.0, #67) — the naval layer, fully parallel to but
// INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES
// (`SEA_ZONES` in shared/protocol.ts); each is under the SOLE CONTROL of at
// most one nation. Controlling a sea zone never moves land (`territory` /
// `unowned` are untouched) and moving land never touches a sea zone.
//
// Each turn the LLM declares `sea.control` ops — one op names the zone and
// the nation that (now) controls it. The engine resolves `by` (id first, then
// a case-insensitive unique name, via the shared `resolveNationRef`) and
// UPSERTS the zone's claim in `GameState.seaZones` (replace in place if the
// zone is already claimed, append if new). If two or more ops this turn claim
// the SAME zone for DIFFERENT resolved nations, the zone is CONTESTED: the
// higher current `stats.military` wins (a tie goes to the op listed earlier in
// the `control` array) — deterministic, no hidden randomness. An unresolvable
// `by` is dropped (never thrown).
//
// Every committed turn — INCLUDING a turn with an empty `control` — each
// existing claim's controller earns a small DETERMINISTIC bonus
// (`SEA_ZONE_BONUS = 1` military + 1 economy, clamped to 0–100, mirrored to
// the controller's regions and re-derived with the same 0-region / no-entry
// guards as the economy / migration layers); a claim whose nation no longer
// exists is skipped (the claim is kept, never thrown). The layer is DURABLE
// (unlike the turn-transient hybrid / disasters / migration layers):
// `GameState.seaZones` persists across turns and in SQLite, and the user
// payload always carries the current `seaZones` list (even when empty).
import {
clampStat,
deriveNationStats,
isSeaZoneId,
} from '../../shared/protocol';
export { SEA_ZONES, SEA_ZONE_IDS, isSeaZoneId, seaZoneName } from '../../shared/protocol';
import type { SeaControlOp, SeaOps } from '../../shared/protocol';
import type { GameState, Nation } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (v0.43.0, #65) The DETERMINISTIC per-turn bonus each sea-zone controller
* earns (applied to BOTH `military` and `economy`, clamped to 0–100, mirrored
* to the controller's regions, and re-derived with the same 0-region /
* no-entry guards as the economy / migration layers). No hidden randomness.
*/
export const SEA_ZONE_BONUS = 1;
/**
* (v0.43.0, #65) Validate the raw `sea` field of a turn into a safe
* {@link SeaOps}. Defensive like the rest of the parser — never throws. The
* raw value may be the object `{ control: [...] }` (the TurnResult shape) or
* a bare array (be lenient about either). Each op needs a valid `zone` (one of
* {@link SEA_ZONES}) and a non-empty `by` (the controlling nation's id or
* unique name, capped at 200 chars). An op with an unknown / missing `zone` or
* an empty / missing `by` is dropped. Resolution of `by` to a concrete nation
* id happens at apply time (`applySeaControl`), not here. A missing /
* malformed top-level value yields `{ control: [] }`.
*/
export function validateSeaOps(raw: unknown): SeaOps {
const out: SeaOps = { control: [] };
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).control;
if (Array.isArray(inner)) list = inner;
}
if (!list) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
const zone = str(o.zone, 100);
if (!zone || !isSeaZoneId(zone)) continue; // unknown / missing zone: drop
const by = str(o.by, 200);
if (!by) continue; // empty / missing controller: drop
out.control.push({ zone, by });
}
return out;
}
/**
* (v0.43.0, #65) Upsert the turn's sea-control ops into `next.seaZones`,
* mutating in place and never throwing. Each op's `by` is resolved against the
* CURRENT state (id first, then a case-insensitive unique name) — an
* unresolvable ref is dropped. Per zone (in first-appearance order): a single
* surviving op SETS or REPLACES the zone's controller (replace the existing
* entry in place — preserving its position — or append a new one); two or
* more surviving ops on the same zone for DIFFERENT resolved nations make the
* zone CONTESTED — the higher current `stats.military` wins (a tie goes to the
* op listed earlier in the `control` array), and the winner becomes the
* controller. A zone whose ops all resolve to the same nation is simply (re-)
* set to that nation. Sea control never touches `territory` / `unowned`.
*/
export function applySeaControl(next: GameState, ops: SeaControlOp[]): void {
// Group the ops by zone, keeping the original (first-appearance) order of
// zones and each zone's ops (so the contest tie-break "earlier in the
// control array" is well-defined).
const byZone = new Map<string, { index: number; by: string }[]>();
ops.forEach((op, index) => {
const list = byZone.get(op.zone) ?? [];
list.push({ index, by: op.by });
byZone.set(op.zone, list);
});
for (const [zone, list] of byZone) {
// Resolve each op's `by`; an unresolvable ref is dropped (the zone is
// still decided by the ops that do resolve).
const resolved = list
.map(({ index, by }) => ({ index, id: resolveNationRef(next, by) }))
.filter((r): r is { index: number; id: string } => r.id !== null);
if (resolved.length === 0) continue; // nothing resolved: zone untouched
// Contest: the highest current `stats.military` wins; a strict `>` keeps
// the EARLIER-listed op on a tie (the list is in control-array order).
let winner = resolved[0];
for (const r of resolved) {
const mw = next.nations[winner.id]?.stats.military ?? 0;
const mr = next.nations[r.id]?.stats.military ?? 0;
if (mr > mw) winner = r;
}
const existing = next.seaZones.find((c) => c.zone === zone);
if (existing) {
existing.by = winner.id; // replace in place (position preserved)
} else {
next.seaZones.push({ zone, by: winner.id });
}
}
}
/**
* (v0.43.0, #65) The per-turn sea bonus, applied EVERY committed turn (not
* just to this turn's claims): each entry of `next.seaZones` grants its
* controller `SEA_ZONE_BONUS` military + `SEA_ZONE_BONUS` economy (clamped
* to 0–100, mirrored to every region the nation owns, then the nation's
* derived `stats` re-derived — the same 0-region / no-entry guards as the
* economy / migration layers, so a 0-region nation keeps its directly-mutated
* value). A nation controlling several zones stacks the bonus; a claim whose
* nation no longer exists (removed by a merge / split this turn) is skipped —
* the claim is kept, never thrown. Never throws.
*/
export function applySeaZoneBonuses(
next: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.seaZones.length === 0) return;
// nation id -> {military, economy} delta (claims for the same nation stack).
const delta = new Map<string, { military: number; economy: number }>();
for (const c of next.seaZones) {
const d = delta.get(c.by) ?? { military: 0, economy: 0 };
d.military += SEA_ZONE_BONUS;
d.economy += SEA_ZONE_BONUS;
delta.set(c.by, d);
}
const applyNation = (n: Nation, d: { military: number; economy: number }): void => {
(['military', 'economy'] as const).forEach((k) => {
n.stats[k] = clampStat(n.stats[k] + d[k]);
});
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped
(['military', 'economy'] as const).forEach((k) => {
rs[k] = clampStat(rs[k] + d[k]);
});
}
// Re-derive the nation's aggregate stats so the effect persists (the main
// recompute ran earlier this turn; without this the change would be washed
// out next turn). A nation with no regionStats entry keeps its
// directly-mutated `stats`.
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
};
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue; // a nation removed this turn: the bonus is dropped (claim kept)
applyNation(n, d);
}
}
export const sea: Ability<'sea'> = {
field: 'sea',
default: () => ({ control: [] }),
validate: (raw) => validateSeaOps(raw),
apply: (state, data, ctx) => {
// (1) APPLY OPS: this turn's control ops upsert `GameState.seaZones`
// (a single op sets/replaces the zone's controller; a same-zone
// contest goes to the higher current `stats.military`, a tie to the
// earlier-listed op).
// (2) PER-TURN BONUS: EVERY committed turn — including this one, and
// including turns with an empty `control` — each existing claim's
// controller earns the deterministic military/economy bonus, so a
// zone that stays held keeps paying out.
applySeaControl(state, data.control);
applySeaZoneBonuses(state, ctx.geoOf);
},
schemaLine: () =>
' "sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},',
constraint: () =>
'- 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).',
isMeaningful: (data) => data.control.length > 0,
stateFields: ['seaZones'],
stub: () => ({ control: [] }),
// Sea control is DURABLE (unlike the turn-transient hybrid / disasters /
// migration layers): the user payload ALWAYS carries the current `seaZones`
// list (even when empty) so the model knows who controls which zone.
payload: (ctx) => ({ seaZones: ctx.seaZones ?? [] }),
};
+6 -3
View File
@@ -12,16 +12,17 @@ import type {
IndustryProject,
RegionResources,
RelationStatus,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 9 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* The 11 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 nine abilities.
* / markers) plus, in order, these eleven abilities.
*/
export type AbilityField =
| 'takeovers'
@@ -33,7 +34,8 @@ export type AbilityField =
| 'leaders'
| 'ideology'
| 'disasters'
| 'migration';
| 'migration'
| 'sea';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
@@ -89,6 +91,7 @@ export interface UserPromptCtx {
readonly embargoes?: Embargo[];
readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[];
readonly seaZones?: SeaZoneClaim[];
}
/**