CI / build + unit + e2e (pull_request) Successful in 2m20s
#80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides). #81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn). Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing.
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
// 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<string, 'ally' | 'neutral' | 'rival' | 'hostile' | 'war'> = {},
|
|
): Nation {
|
|
return { id, name, type: 'major', stats, territory, relations };
|
|
}
|
|
|
|
function mkState(
|
|
nations: Record<string, Nation>,
|
|
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' },
|
|
]);
|
|
});
|
|
});
|