Files
History/server/abilities/sea.ts
T
kamilkrzywda c77e81c45b
CI / build + unit + e2e (pull_request) Successful in 2m33s
feat: #65 sea control — 11th ability (v0.43.0)
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.
2026-10-16 12:50:22 +00:00

211 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ?? [] }),
};