CI / build + unit + e2e (pull_request) Successful in 2m10s
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.
497 lines
18 KiB
TypeScript
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));
|
|
});
|
|
});
|