// 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, regionStats: Record, 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); }); });