Files
History/server/abilities/space.test.ts
T
kamilkrzywda 283f9e0306
CI / build + unit + e2e (pull_request) Successful in 2m20s
feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
#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.
2026-10-17 16:54:28 +00:00

414 lines
16 KiB
TypeScript

// 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);
});
});