Files
History/server/abilities/sea.test.ts
T
kamilkrzywda 5477336537
CI / build + unit + e2e (pull_request) Successful in 2m10s
feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
2026-10-16 15:21:45 +00:00

497 lines
18 KiB
TypeScript

// 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<string, Nation>,
regionStats: Record<string, RegionStats>,
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,
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
// ---------------------------------------------------------------------------
// 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));
});
});