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,413 @@
|
||||
// Unit tests for the SPACE PROGRAM / satellites ability (v0.45.0, #81) — the
|
||||
// modular, DURABLE LLM-ability that lets nations launch satellites
|
||||
// (`space.launch`) over regions they own and deorbit them (`space.deorbit`):
|
||||
// each active satellite grants its owner +1 stability per committed turn
|
||||
// (`SATELLITE_STABILITY_BONUS`), mirrored to its regions and re-derived, and a
|
||||
// nation with several satellites stacks the bonus. 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 a space op, so the apply path is
|
||||
// exercised here, not by the e2e suite.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applySatelliteBonuses,
|
||||
applySpaceControl,
|
||||
isSatelliteType,
|
||||
space,
|
||||
validateSpaceOps,
|
||||
SATELLITE_STABILITY_BONUS,
|
||||
SATELLITE_TYPES,
|
||||
} from './space';
|
||||
|
||||
import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types';
|
||||
import type { Satellite } from '../../shared/protocol';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NS(military: number, economy: number, stability: number, population: number): NationStats {
|
||||
return { military, economy, stability, population };
|
||||
}
|
||||
|
||||
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
|
||||
return { military, economy, stability, population };
|
||||
}
|
||||
|
||||
function mkNation(
|
||||
id: string,
|
||||
name: string,
|
||||
stats: NationStats,
|
||||
territory: string[],
|
||||
): Nation {
|
||||
return { id, name, type: 'major', stats, territory, relations: {} };
|
||||
}
|
||||
|
||||
function mkState(
|
||||
nations: Record<string, Nation>,
|
||||
regionStats: Record<string, RegionStats>,
|
||||
satellites: Satellite[] = [],
|
||||
): 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, validRegionKeys: new Set(['a-r1', 'b-r1', 'c-r1']) });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SATELLITE_TYPES / isSatelliteType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SATELLITE_TYPES / isSatelliteType', () => {
|
||||
it('exposes exactly the 4 satellite types', () => {
|
||||
expect(SATELLITE_TYPES).toEqual(['recon', 'comms', 'navigation', 'weather']);
|
||||
});
|
||||
|
||||
it('isSatelliteType accepts each known type and rejects unknown / non-strings', () => {
|
||||
for (const t of SATELLITE_TYPES) expect(isSatelliteType(t)).toBe(true);
|
||||
expect(isSatelliteType('weapon')).toBe(false);
|
||||
expect(isSatelliteType('')).toBe(false);
|
||||
expect(isSatelliteType(42 as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('SATELLITE_STABILITY_BONUS is +1', () => {
|
||||
expect(SATELLITE_STABILITY_BONUS).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateSpaceOps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateSpaceOps', () => {
|
||||
it('accepts the { launch, deorbit } object shape', () => {
|
||||
const op = { by: 'a1', regionKey: 'a-r1', type: 'recon' };
|
||||
expect(validateSpaceOps({ launch: [op], deorbit: ['s1'] })).toEqual({
|
||||
launch: [op],
|
||||
deorbit: ['s1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a launch op with an unknown / missing type', () => {
|
||||
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'weapon' }], deorbit: [] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: [],
|
||||
});
|
||||
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1' }], deorbit: [] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a launch op with an empty / missing `by` or `regionKey`', () => {
|
||||
expect(validateSpaceOps({ launch: [{ by: '', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: [],
|
||||
});
|
||||
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: '', type: 'recon' }], deorbit: [] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a deorbit entry that is empty / non-string', () => {
|
||||
expect(validateSpaceOps({ launch: [], deorbit: ['', 42, null, 's1'] as unknown as string[] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: ['s1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes deorbit ids', () => {
|
||||
expect(validateSpaceOps({ launch: [], deorbit: ['s1', 's1', 's2', 's2'] })).toEqual({
|
||||
launch: [],
|
||||
deorbit: ['s1', 's2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('caps `by` at 200 and `regionKey` at 120 chars', () => {
|
||||
const longBy = 'f'.repeat(500);
|
||||
const longKey = 'g'.repeat(500);
|
||||
const out = validateSpaceOps({ launch: [{ by: longBy, regionKey: longKey, type: 'recon' }], deorbit: [] });
|
||||
expect(out.launch).toHaveLength(1);
|
||||
expect(out.launch[0].by).toHaveLength(200);
|
||||
expect(out.launch[0].regionKey).toHaveLength(120);
|
||||
});
|
||||
|
||||
it('a missing / malformed top-level value yields { launch: [], deorbit: [] }', () => {
|
||||
expect(validateSpaceOps(undefined)).toEqual({ launch: [], deorbit: [] });
|
||||
expect(validateSpaceOps(null)).toEqual({ launch: [], deorbit: [] });
|
||||
expect(validateSpaceOps('nope')).toEqual({ launch: [], deorbit: [] });
|
||||
expect(validateSpaceOps({ launch: 'nope' })).toEqual({ launch: [], deorbit: [] });
|
||||
expect(validateSpaceOps({ deorbit: 'nope' })).toEqual({ launch: [], deorbit: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySpaceControl (deorbit first, then launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applySpaceControl', () => {
|
||||
it('a launch adds a fresh satellite stamped with the turn and date', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
);
|
||||
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||
|
||||
expect(state.satellites).toEqual([
|
||||
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves `by` by unique name (not just id)', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
);
|
||||
applySpaceControl(state, { launch: [{ by: 'Alpha', regionKey: 'a-r1', type: 'comms' }], deorbit: [] }, ctx(state));
|
||||
expect(state.satellites).toHaveLength(1);
|
||||
expect(state.satellites[0].by).toBe('a1');
|
||||
});
|
||||
|
||||
it('an unresolvable `by` drops the launch', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
);
|
||||
applySpaceControl(state, { launch: [{ by: 'ghost', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||
expect(state.satellites).toEqual([]);
|
||||
});
|
||||
|
||||
it('an unowned or invalid regionKey drops the launch', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
);
|
||||
// a-r2 is not owned by a1
|
||||
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r2', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||
expect(state.satellites).toEqual([]);
|
||||
|
||||
// not-a-key is not a valid key
|
||||
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'not-a-key', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||
expect(state.satellites).toEqual([]);
|
||||
});
|
||||
|
||||
it('deorbit removes satellites by id; an absent id 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) },
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySpaceControl(state, { launch: [], deorbit: ['s1', 's999'] }, ctx(state));
|
||||
expect(state.satellites).toEqual([]);
|
||||
});
|
||||
|
||||
it('deorbit is processed before launch in the same turn', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySpaceControl(
|
||||
state,
|
||||
{ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'comms' }], deorbit: ['s1'] },
|
||||
ctx(state),
|
||||
);
|
||||
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1 — id reused
|
||||
expect(state.satellites).toEqual([
|
||||
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
]);
|
||||
});
|
||||
|
||||
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) },
|
||||
);
|
||||
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
|
||||
expect(state.nations.a1.territory).toEqual(['a-r1']);
|
||||
expect(state.unowned).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySatelliteBonuses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applySatelliteBonuses', () => {
|
||||
it('each active satellite grants its owner +1 stability (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) },
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
|
||||
// +1 stability, mirrored to the region, re-derived (single region → back to region's value)
|
||||
expect(state.nations.a1.stats).toEqual(NS(50, 50, 51, 50));
|
||||
expect(state.regionStats['a-r1']).toEqual(RS(50, 50, 51, 50));
|
||||
});
|
||||
|
||||
it('clamps to 0 and 100', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 99, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 99, 50) },
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
expect(state.nations.a1.stats.stability).toBe(100);
|
||||
expect(state.regionStats['a-r1'].stability).toBe(100);
|
||||
});
|
||||
|
||||
it('a nation with several satellites stacks the bonus (+1 per satellite)', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
[
|
||||
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
{ id: 's2', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
{ id: 's3', by: 'a1', regionKey: 'a-r1', type: 'weather', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
expect(state.nations.a1.stats.stability).toBe(53);
|
||||
expect(state.regionStats['a-r1'].stability).toBe(53);
|
||||
});
|
||||
|
||||
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), []) },
|
||||
{},
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
// direct mutation, no re-derive (no regions)
|
||||
expect(state.nations.a1.stats.stability).toBe(51);
|
||||
});
|
||||
|
||||
it('a stale satellite (nation no longer in `nations`) is skipped; the satellite is kept', () => {
|
||||
const state = mkState(
|
||||
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
|
||||
{ 'a-r1': RS(50, 50, 50, 50) },
|
||||
[{ id: 's1', by: 'ghost', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
// ghost is not in nations → skipped; a1 is unaffected
|
||||
expect(state.nations.a1.stats.stability).toBe(50);
|
||||
// the satellite is kept (not removed)
|
||||
expect(state.satellites).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an empty satellites 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) },
|
||||
[],
|
||||
);
|
||||
applySatelliteBonuses(state);
|
||||
expect(state.nations.a1.stats.stability).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Ability object contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('space ability', () => {
|
||||
it('owns the "space" field; default() is a fresh empty { launch: [], deorbit: [] }', () => {
|
||||
expect(space.field).toBe('space');
|
||||
expect(space.default()).toEqual({ launch: [], deorbit: [] });
|
||||
expect(space.default()).not.toBe(space.default());
|
||||
});
|
||||
|
||||
it('default() is not meaningful; a populated value is', () => {
|
||||
expect(space.isMeaningful(space.default())).toBe(false);
|
||||
expect(space.isMeaningful({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toBe(true);
|
||||
expect(space.isMeaningful({ launch: [], deorbit: ['s1'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('validate() wraps the validator into the SpaceOps shape', () => {
|
||||
expect(space.validate({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
|
||||
launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }],
|
||||
deorbit: [],
|
||||
});
|
||||
expect(space.validate(undefined)).toEqual({ launch: [], deorbit: [] });
|
||||
});
|
||||
|
||||
it('the stub value equals default() (the stub never proposes a space op)', () => {
|
||||
expect(space.stub()).toEqual({ launch: [], deorbit: [] });
|
||||
});
|
||||
|
||||
it('stateFields is [satellites] (the durable GameState field)', () => {
|
||||
expect(space.stateFields).toEqual(['satellites']);
|
||||
});
|
||||
|
||||
it('payload() emits satellites (even when empty / undefined)', () => {
|
||||
expect(space.payload({ state: { satellites: [] } } as never)).toEqual({ satellites: [] });
|
||||
expect(space.payload({ state: {} as never })).toEqual({ satellites: [] });
|
||||
expect(
|
||||
space.payload({
|
||||
state: { satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }] },
|
||||
} as never),
|
||||
).toEqual({
|
||||
satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('apply() runs deorbit, then launch, 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(50, 50, 50, 50), ['b-r1']),
|
||||
},
|
||||
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) },
|
||||
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
|
||||
);
|
||||
const c = ctx(state);
|
||||
space.apply(
|
||||
state,
|
||||
{
|
||||
launch: [
|
||||
{ by: 'a1', regionKey: 'a-r1', type: 'comms' },
|
||||
// an unresolvable ref — must be dropped, not throw
|
||||
{ by: 'ghost', regionKey: 'c-r1', type: 'weather' },
|
||||
],
|
||||
deorbit: ['s1'],
|
||||
},
|
||||
c,
|
||||
);
|
||||
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1
|
||||
expect(state.satellites).toEqual([
|
||||
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
|
||||
]);
|
||||
// a1 gets +1 stability (one satellite)
|
||||
expect(state.nations.a1.stats.stability).toBe(51);
|
||||
// b2 is unaffected
|
||||
expect(state.nations.b2.stats.stability).toBe(50);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user