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
+93
View File
@@ -707,6 +707,82 @@ export interface MigrationOps {
ops: MigrationOp[];
}
// ---------------------------------------------------------------------------
// (v0.43.0, #65) Sea zones & sea control — the naval layer, fully parallel to
// but INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES
// ({@link SEA_ZONES}); 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. `SeaZoneClaim` (the
// durable per-zone record, `{ zone, by }`) lives in `shared/types.ts` like
// `Trade` / `Embargo` / `War` / `IndustryProject`; `GameState.seaZones` is a
// list of them, unique per zone (`[]` at creation; a legacy SQLite row
// without the field loads as `[]` — additive, never wiped). Each turn the LLM
// declares `TurnResult.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) and UPSERTS the zone's claim (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. The user payload carries the current
// `seaZones` list so the model knows who controls which zone.
/** The 12 named sea zones the world model knows (v0.43.0, #65). */
export const SEA_ZONES = [
{ id: 'north-atlantic', name: 'North Atlantic' },
{ id: 'south-atlantic', name: 'South Atlantic' },
{ id: 'pacific', name: 'Pacific Ocean' },
{ id: 'indian', name: 'Indian Ocean' },
{ id: 'mediterranean', name: 'Mediterranean Sea' },
{ id: 'north-sea', name: 'North Sea' },
{ id: 'baltic', name: 'Baltic Sea' },
{ id: 'arctic', name: 'Arctic Ocean' },
{ id: 'black-sea', name: 'Black Sea' },
{ id: 'gulf-mexico', name: 'Gulf of Mexico' },
{ id: 'red-sea', name: 'Red Sea' },
{ id: 'sargasso', name: 'Sargasso Sea' },
] as const;
/** The set of valid sea-zone ids (v0.43.0, #65). */
export const SEA_ZONE_IDS: ReadonlySet<string> = new Set(SEA_ZONES.map((z) => z.id));
/** Whether `id` is one of the fixed sea-zone ids (v0.43.0, #65). */
export function isSeaZoneId(id: string): boolean {
return SEA_ZONE_IDS.has(id);
}
/** The display name of a sea-zone id; an unknown id is returned as-is (v0.43.0, #65). */
export function seaZoneName(id: string): string {
return SEA_ZONES.find((z) => z.id === id)?.name ?? id;
}
/**
* A single sea-control op (v0.43.0, #65): `zone` is one of the fixed
* {@link SEA_ZONES} ids and `by` is the nation that (now) controls it (by id
* or name). One op SETS or REPLACES the zone's controller; two or more ops on
* the same zone for different nations are CONTESTED — the higher current
* `stats.military` wins (a tie: the earlier-listed op).
*/
export interface SeaControlOp {
/** The sea zone (one of {@link SEA_ZONE_IDS}). */
zone: string;
/** The controlling nation (by id or name). */
by: string;
}
/** The sea-control block of a `TurnResult`. Required; default `{ control: [] }`. */
export interface SeaOps {
control: SeaControlOp[];
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -899,6 +975,23 @@ export interface TurnResult {
* `{ ops: [] }`.
*/
migration: MigrationOps;
/**
* (v0.43.0, #65) Sea-zone control ops this turn — the naval layer, fully
* parallel to but INDEPENDENT of land: each op names one of the 12 fixed
* sea zones (`SEA_ZONES`) and the nation that now controls it. The engine
* resolves `by` (id first, then a case-insensitive unique name) and
* upserts the zone's claim in `GameState.seaZones` (a single op sets or
* replaces the controller; two or more ops on the same zone for different
* nations are CONTESTED — the higher current `stats.military` wins, a tie
* goes to the op listed earlier in `control`; an unresolvable `by` is
* dropped, never thrown). Every committed turn, each existing claim's
* controller earns a small deterministic military/economy bonus
* (`SEA_ZONE_BONUS`). Sea control never moves land: `territory` and
* `unowned` are untouched. At most 1–2 per turn, each narrated in `events`
* (a `military` or `diplomatic` event). Required; default
* `{ control: [] }`.
*/
sea: SeaOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
+2
View File
@@ -461,6 +461,8 @@ export function worldDraftToGameState(
embargoes: [],
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
return state;
}
+31
View File
@@ -249,6 +249,23 @@ export interface IndustryProject {
type: IndustryType;
}
/**
* (v0.43.0, #65) One sea-zone claim: which nation controls one of the fixed
* sea zones (see `SEA_ZONES` in `shared/protocol.ts`). The world keeps at
* most ONE claim per zone — a new op on an already-claimed zone REPLACES the
* controller in place (the zone's entry is updated, never duplicated), and if
* a turn declares the same zone for two different resolved nations the higher
* current `stats.military` wins (a tie: the op listed earlier in the turn's
* `control` array). Sea control is fully parallel to but INDEPENDENT of land:
* it never touches `territory` / `unowned`.
*/
export interface SeaZoneClaim {
/** The sea zone (one of `SEA_ZONE_IDS`). */
zone: string;
/** The id of the nation that controls it (resolved, not a name). */
by: string;
}
/**
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
* between two or more nations. Unlike the boolean `war` relation (which a
@@ -443,6 +460,20 @@ export interface GameState {
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
*/
industry: IndustryProject[];
/**
* (v0.43.0, #65) Sea-zone control (see {@link SeaZoneClaim}): one entry per
* controlled sea zone — which of the 12 fixed zones (`SEA_ZONES`) each
* nation controls. At most ONE entry per zone (unique by `zone`); a turn's
* `sea.control` ops upsert entries (a re-claim by a different nation
* replaces the controller in place; a same-zone contest goes to the higher
* current `stats.military`, a tie to the earlier-listed op). Every
* committed turn each claim's controller earns a small deterministic
* military/economy bonus (`SEA_ZONE_BONUS`). Sea control never moves land
* (`territory` / `unowned` are untouched). Always `[]` on a fresh game;
* rows saved before v0.43 load with this defaulted to `[]` (see
* server/db.ts) — additive, no data wipe.
*/
seaZones: SeaZoneClaim[];
}
/**