// 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).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; 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(); 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(); 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":"","by":""}]},', 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":"","by":""}) 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 ?? [] }), };