feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
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.
This commit is contained in:
2026-10-17 16:54:28 +00:00
parent 0a6c5aef9c
commit 283f9e0306
26 changed files with 1493 additions and 49 deletions
+380
View File
@@ -0,0 +1,380 @@
// 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' },
]);
});
});