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.
211 lines
11 KiB
TypeScript
211 lines
11 KiB
TypeScript
// 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 ?? [] }),
|
||
};
|