feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
CI / build + unit + e2e (pull_request) Successful in 2m20s
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:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user