// 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, regionStats: Record, 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)); }); });