feat: #65 sea control — 11th ability (v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -205,5 +205,6 @@
|
||||
"regionKey": "a-r1",
|
||||
"type": "industry"
|
||||
}
|
||||
]
|
||||
],
|
||||
"seaZones": []
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -82,6 +82,8 @@ function mkState(
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
industry: [],
|
||||
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
||||
seaZones: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [] }),
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user