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.
414 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|