// Unit tests for the ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) — // the modular, DURABLE LLM-ability that lets nations form symmetric pacts // (`alliances.form`) and break them (`alliances.break`): a pact sets the pair's // relation to `ally` (unless an active war overrides), breaking sets it back to // `neutral` (unless an active war still binds them), a pair holds at most one // pact, and the pacts live in `GameState.alliances`. The layer never touches // `territory` / `unowned`. // 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 an alliance op, so the apply // path is exercised here, not by the e2e suite. import { describe, expect, it } from 'vitest'; import { applyAlliances, alliances, freshAllianceId, validateAlliancesOps, } from './alliances'; import type { Alliance } from '../../shared/protocol'; import type { GameState, Nation, NationStats } from '../../shared/types'; // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- function NS(military: number, economy: number, stability: number, population: number): NationStats { return { military, economy, stability, population }; } function mkNation( id: string, name: string, stats: NationStats, territory: string[], relations: Record = {}, ): Nation { return { id, name, type: 'major', stats, territory, relations }; } function mkState( nations: Record, alliances: Alliance[] = [], ): 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: [], contested: [], alliances, satellites: [], }; } const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn }); // --------------------------------------------------------------------------- // freshAllianceId // --------------------------------------------------------------------------- describe('freshAllianceId', () => { it('returns the first free alN id', () => { expect(freshAllianceId(new Set())).toBe('al1'); expect(freshAllianceId(new Set(['al1']))).toBe('al2'); expect(freshAllianceId(new Set(['al1', 'al2']))).toBe('al3'); }); it('skips ids already taken (a removed pact\'s id is never reused within a turn)', () => { expect(freshAllianceId(new Set(['al1', 'al3']))).toBe('al2'); }); }); // --------------------------------------------------------------------------- // validateAlliancesOps // --------------------------------------------------------------------------- describe('validateAlliancesOps', () => { it('accepts the { form, break } object shape', () => { expect( validateAlliancesOps({ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'b2', b: 'c3' }], }), ).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'b2', b: 'c3' }], }); }); it('drops ops with empty / missing a or b, or a === b (self-pairing)', () => { expect(validateAlliancesOps({ form: [{ a: 'a1', b: '' }], break: [] })).toEqual({ form: [], break: [], }); expect(validateAlliancesOps({ form: [{ a: '', b: 'b2' }], break: [] })).toEqual({ form: [], break: [], }); expect(validateAlliancesOps({ form: [{ a: 'a1', b: 'a1' }], break: [] })).toEqual({ form: [], break: [], }); }); it('drops ops with non-object entries', () => { expect( validateAlliancesOps({ form: [42, 'nope', null, { a: 'a1', b: 'b2' }], break: [], }), ).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [] }); }); it('a missing / malformed top-level value yields { form: [], break: [] }', () => { expect(validateAlliancesOps(undefined)).toEqual({ form: [], break: [] }); expect(validateAlliancesOps(null)).toEqual({ form: [], break: [] }); expect(validateAlliancesOps('nope')).toEqual({ form: [], break: [] }); expect(validateAlliancesOps({ form: 'nope' })).toEqual({ form: [], break: [] }); expect(validateAlliancesOps({ break: 'nope' })).toEqual({ form: [], break: [] }); }); it('caps `a` and `b` at 200 chars', () => { const longA = 'f'.repeat(500); const longB = 'g'.repeat(500); // different string so a !== b after capping const out = validateAlliancesOps({ form: [{ a: longA, b: longB }], break: [] }); expect(out.form).toHaveLength(1); expect(out.form[0].a).toHaveLength(200); expect(out.form[0].b).toHaveLength(200); }); }); // --------------------------------------------------------------------------- // applyAlliances // --------------------------------------------------------------------------- describe('applyAlliances', () => { it('form creates a pact and sets the pair to ally', () => { const state = mkState({ a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }); applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); expect(state.alliances).toEqual([ { id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }, ]); expect(state.nations.a1.relations.b2).toBe('ally'); expect(state.nations.b2.relations.a1).toBe('ally'); }); it('resolves `a`/`b` by unique name (not just id)', () => { const state = mkState({ a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }); applyAlliances(state, { form: [{ a: 'Alpha', b: 'Bravo' }], break: [] }, ctx(state)); expect(state.alliances).toHaveLength(1); expect(state.alliances[0].parties).toEqual(['a1', 'b2']); }); it('an unresolvable ref drops the op', () => { const state = mkState({ a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }); applyAlliances(state, { form: [{ a: 'a1', b: 'ghost' }], break: [] }, ctx(state)); expect(state.alliances).toEqual([]); expect(state.nations.a1.relations).toEqual({}); }); it('an ambiguous name (two nations share it) drops the op', () => { const state = mkState({ a1: mkNation('a1', 'Twin', NS(60, 55, 70, 80), []), a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), []), }); applyAlliances(state, { form: [{ a: 'Twin', b: 'a1' }], break: [] }, ctx(state)); // 'Twin' is ambiguous → dropped expect(state.alliances).toEqual([]); }); it('break removes an existing pact and sets the pair to neutral', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'ally' }), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'ally' }), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); expect(state.alliances).toEqual([]); expect(state.nations.a1.relations.b2).toBe('neutral'); expect(state.nations.b2.relations.a1).toBe('neutral'); }); it('breaking a non-existent pact is a no-op', () => { const state = mkState({ a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }); applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); expect(state.alliances).toEqual([]); expect(state.nations.a1.relations).toEqual({}); }); it('re-forming an existing pact is idempotent (no duplicate)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); expect(state.alliances).toHaveLength(1); expect(state.alliances[0].formedTurn).toBe(1); // unchanged }); it('break then re-form the same pair in one turn ends with a fresh pact', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); applyAlliances( state, { form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] }, ctx(state), ); expect(state.alliances).toHaveLength(1); expect(state.alliances[0].id).toBe('al1'); // id reused (al1 was freed by break) expect(state.alliances[0].formedTurn).toBe(1); }); it('form when the pair is at war: the pact forms but the relation stays war', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }), }, [], // wire up an active war so atWar(a1, b2) is true ); state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }]; applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); expect(state.alliances).toHaveLength(1); // pact still formed expect(state.alliances[0].parties).toEqual(['a1', 'b2']); // the relation stays 'war' (war overrides ally) expect(state.nations.a1.relations.b2).toBe('war'); expect(state.nations.b2.relations.a1).toBe('war'); }); it('break when the pair is at war: the pact breaks but the relation stays war', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }]; applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); expect(state.alliances).toEqual([]); // pact removed // the relation stays 'war' (war overrides neutral) expect(state.nations.a1.relations.b2).toBe('war'); expect(state.nations.b2.relations.a1).toBe('war'); }); it('pairs are order-independent: form {a: b2, b: a1} matches an existing {a1, b2} pact', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); applyAlliances(state, { form: [{ a: 'b2', b: 'a1' }], break: [] }, ctx(state)); // already allied (order-independent) → no duplicate expect(state.alliances).toHaveLength(1); }); it('never touches `territory` or `unowned`', () => { const state = mkState({ a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), ['b-r1']), }); applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); expect(state.nations.a1.territory).toEqual(['a-r1']); expect(state.nations.b2.territory).toEqual(['b-r1']); expect(state.unowned).toEqual([]); }); }); // --------------------------------------------------------------------------- // The Ability object contract // --------------------------------------------------------------------------- describe('alliances ability', () => { it('owns the "alliances" field; default() is a fresh empty { form: [], break: [] }', () => { expect(alliances.field).toBe('alliances'); expect(alliances.default()).toEqual({ form: [], break: [] }); expect(alliances.default()).not.toBe(alliances.default()); }); it('default() is not meaningful; a populated value is', () => { expect(alliances.isMeaningful(alliances.default())).toBe(false); expect(alliances.isMeaningful({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toBe(true); expect(alliances.isMeaningful({ form: [], break: [{ a: 'a1', b: 'b2' }] })).toBe(true); }); it('validate() wraps the validator into the AllianceOps shape', () => { expect(alliances.validate({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [], }); expect(alliances.validate(undefined)).toEqual({ form: [], break: [] }); }); it('the stub value equals default() (the stub never proposes an alliance op)', () => { expect(alliances.stub()).toEqual({ form: [], break: [] }); }); it('stateFields is [alliances] (the durable GameState field)', () => { expect(alliances.stateFields).toEqual(['alliances']); }); it('payload() emits alliances (even when empty / undefined)', () => { expect(alliances.payload({ state: { alliances: [] } } as never)).toEqual({ alliances: [] }); expect(alliances.payload({ state: {} as never })).toEqual({ alliances: [] }); expect( alliances.payload({ state: { alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }] }, } as never), ).toEqual({ alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], }); }); it('apply() runs form then break (break processed first)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), }, [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], ); const c = ctx(state); alliances.apply( state, { form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] }, c, ); // break first removes al1 (freeing the id), then form creates al1 again (id reused) expect(state.alliances).toEqual([ { id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }, ]); }); });