// Unit tests for the MIGRATION & REFUGEES ability (v0.42.0, #64) — the // modular, turn-transient LLM-ability that moves population between nations: // the source loses a small amount of population and economy, the destination // gains a small amount (a `large` influx also strains the destination's // stability), each side's aggregate stats are re-derived, and the from↔to // relation is nudged one step toward the positive end of the ladder (capped by // kind; a war is never touched). 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 migration, so the apply path is exercised here, not by the e2e // suite. import { describe, expect, it } from 'vitest'; import { LARGE_INFLUX_STABILITY_DIP, MIGRATION_DESTINATION_EFFECTS, MIGRATION_KINDS, MIGRATION_SCALES, MIGRATION_SOURCE_EFFECTS, applyMigrationOp, isMigrationKind, isMigrationScale, migration, shiftRelationTowardPositive, validateMigrationOps, } from './migration'; import type { MigrationScale } from '../../shared/protocol'; import type { GameState, Nation, NationStats, RelationStatus, RegionStats } from '../../shared/types'; // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- /** Build a NationStats from the four canonical indices (military, economy, stability, population). */ function NS(military: number, economy: number, stability: number, population: number): NationStats { return { military, economy, stability, population }; } /** Build a RegionStats from the four canonical indices. */ function RS(military: number, economy: number, stability: number, population: number): RegionStats { return { military, economy, stability, population }; } /** A minimal nation: the given id/name/stats/region-key territory, given relations. */ function mkNation( id: string, name: string, stats: NationStats, territory: string[], relations: Record = {}, ): Nation { return { id, name, type: 'major', stats, territory, relations }; } /** * A minimal GameState carrying the given nations (keyed by id) and per-region * stats. Every other required field is at its fresh-game baseline, so a test * can focus purely on the migration effect. */ function mkState( nations: Record, regionStats: Record, ): GameState { return { id: 'g1', presetId: 'test', presetTitle: 'Test', playerNationId: Object.keys(nations)[0] ?? 'uk', turn: 1, date: '1939-09-01', nations, events: [], pending: [], version: 1, unowned: [], pendingNationOps: [], markers: [], regionStats, wars: [], trades: [], embargoes: [], industry: [], // (v0.43.0, #65) no sea-zone control on a fresh state. seaZones: [], }; } // --------------------------------------------------------------------------- // Kind / scale constants + predicates // --------------------------------------------------------------------------- describe('MIGRATION_KINDS / MIGRATION_SCALES', () => { it('exposes exactly the four kinds and three scales', () => { expect([...MIGRATION_KINDS]).toEqual(['migration', 'refugees', 'diaspora', 'exodus']); expect([...MIGRATION_SCALES]).toEqual(['small', 'moderate', 'large']); }); }); describe('isMigrationKind / isMigrationScale', () => { it('accepts each known kind and rejects unknown / non-strings', () => { for (const k of MIGRATION_KINDS) expect(isMigrationKind(k)).toBe(true); expect(isMigrationKind('immigration')).toBe(false); expect(isMigrationKind(42)).toBe(false); expect(isMigrationKind(undefined)).toBe(false); }); it('accepts each known scale and rejects unknown / non-strings', () => { for (const s of MIGRATION_SCALES) expect(isMigrationScale(s)).toBe(true); expect(isMigrationScale('massive')).toBe(false); expect(isMigrationScale(42)).toBe(false); }); }); // --------------------------------------------------------------------------- // Effect tables // --------------------------------------------------------------------------- describe('MIGRATION_SOURCE_EFFECTS / MIGRATION_DESTINATION_EFFECTS', () => { it('every source scale dips exactly population and economy (both negative)', () => { for (const s of MIGRATION_SCALES) { const dips = MIGRATION_SOURCE_EFFECTS[s]; expect(Object.keys(dips).sort()).toEqual(['economy', 'population']); expect(dips.population).toBeLessThan(0); expect(dips.economy).toBeLessThan(0); } }); it('every destination scale gains exactly population and economy (both positive)', () => { for (const s of MIGRATION_SCALES) { const gains = MIGRATION_DESTINATION_EFFECTS[s]; expect(Object.keys(gains).sort()).toEqual(['economy', 'population']); expect(gains.population).toBeGreaterThan(0); expect(gains.economy).toBeGreaterThan(0); } }); it('source loses and destination gains monotonically with scale', () => { const [small, moderate, large] = MIGRATION_SCALES; const srcPop = (s: MigrationScale) => MIGRATION_SOURCE_EFFECTS[s].population!; const dstPop = (s: MigrationScale) => MIGRATION_DESTINATION_EFFECTS[s].population!; // A larger scale means the source LOSES more: the (negative) dip grows more // negative as the scale grows, i.e. small > moderate > large. expect(srcPop(small)).toBeGreaterThan(srcPop(moderate)); expect(srcPop(moderate)).toBeGreaterThan(srcPop(large)); // A larger scale means the destination GAINS more: the (positive) gain // grows larger as the scale grows, i.e. small < moderate < large. expect(dstPop(small)).toBeLessThan(dstPop(moderate)); expect(dstPop(moderate)).toBeLessThan(dstPop(large)); }); it('the large-influx stability strain is a small negative dip', () => { expect(LARGE_INFLUX_STABILITY_DIP).toBeLessThan(0); }); }); // --------------------------------------------------------------------------- // validateMigrationOps // --------------------------------------------------------------------------- describe('validateMigrationOps', () => { it('accepts the { ops: [...] } object shape and a bare array', () => { const op = { kind: 'migration', from: 'a', to: 'b', scale: 'small' }; expect(validateMigrationOps({ ops: [op] })).toEqual([op]); expect(validateMigrationOps([op])).toEqual([op]); }); it('drops an op with an unknown / missing kind', () => { expect(validateMigrationOps([{ kind: 'tsunami', from: 'a', to: 'b', scale: 'small' }])).toEqual( [], ); expect( validateMigrationOps([ { from: 'a', to: 'b', scale: 'small' }, { kind: 'migration', from: 'a', to: 'b', scale: 'small' }, ]), ).toEqual([{ kind: 'migration', from: 'a', to: 'b', scale: 'small' }]); }); it('drops an op with an unknown / missing scale', () => { expect(validateMigrationOps([{ kind: 'migration', from: 'a', to: 'b', scale: 'massive' }])).toEqual( [], ); expect(validateMigrationOps([{ kind: 'migration', from: 'a', to: 'b' }])).toEqual([]); }); it('drops an op with an empty / missing from (the source)', () => { expect(validateMigrationOps([{ kind: 'migration', from: '', to: 'b', scale: 'small' }])).toEqual( [], ); expect(validateMigrationOps([{ kind: 'migration', to: 'b', scale: 'small' }])).toEqual([]); }); it('requires a non-empty `to` for every kind except exodus', () => { // non-exodus kind missing `to`: dropped expect(validateMigrationOps([{ kind: 'migration', from: 'a', scale: 'small' }])).toEqual([]); expect(validateMigrationOps([{ kind: 'refugees', from: 'a', scale: 'small' }])).toEqual([]); // exodus without a `to`: kept (an exodus has no destination) expect(validateMigrationOps([{ kind: 'exodus', from: 'a', scale: 'small' }])).toEqual([ { kind: 'exodus', from: 'a', scale: 'small' }, ]); }); it('caps `from` / `to` at 200 chars and `detail` at 300, dropping empty detail', () => { const longFrom = 'f'.repeat(500); const longDetail = 'd'.repeat(500); const out = validateMigrationOps([ { kind: 'migration', from: longFrom, to: 'b', scale: 'small', detail: longDetail }, ]); expect(out).toHaveLength(1); expect(out[0].from).toHaveLength(200); expect(out[0].detail).toHaveLength(300); }); it('a missing / malformed top-level value yields an empty list', () => { expect(validateMigrationOps(undefined)).toEqual([]); expect(validateMigrationOps(null)).toEqual([]); expect(validateMigrationOps('nope')).toEqual([]); expect(validateMigrationOps({ ops: 'nope' })).toEqual([]); expect(validateMigrationOps([{ kind: 'migration' }])).toEqual([]); }); }); // --------------------------------------------------------------------------- // shiftRelationTowardPositive // --------------------------------------------------------------------------- describe('shiftRelationTowardPositive', () => { function pair(rel: Record): GameState { return mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1'], rel), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1'], rel), }, { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, ); } it('nudges a neutral pair one step toward positive, symmetrically', () => { const s = pair({ b2: 'neutral', a1: 'neutral' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('ally'); expect(s.nations.b2.relations.a1).toBe('ally'); }); it('never moves past the cap (migration/diaspora cap at neutral)', () => { // hostile → rival (one step toward positive, still below the neutral cap) let s = pair({ b2: 'hostile', a1: 'hostile' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); expect(s.nations.a1.relations.b2).toBe('rival'); // rival → neutral (reaches the cap) s = pair({ b2: 'rival', a1: 'rival' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); expect(s.nations.a1.relations.b2).toBe('neutral'); // neutral → stays neutral (at the cap) s = pair({ b2: 'neutral', a1: 'neutral' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); expect(s.nations.a1.relations.b2).toBe('neutral'); // ally → stays ally (more positive than the cap) s = pair({ b2: 'ally', a1: 'ally' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); expect(s.nations.a1.relations.b2).toBe('ally'); }); it('refugees can nudge all the way to ally (cap ally)', () => { let s = pair({ b2: 'hostile', a1: 'hostile' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('rival'); s = pair({ b2: 'rival', a1: 'rival' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('neutral'); s = pair({ b2: 'neutral', a1: 'neutral' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('ally'); // already ally: no change s = pair({ b2: 'ally', a1: 'ally' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('ally'); }); it('a missing relation starts at neutral and nudges toward the cap', () => { const s = pair({}); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('ally'); expect(s.nations.b2.relations.a1).toBe('ally'); }); it('never touches a war relation', () => { const s = pair({ b2: 'war', a1: 'war' }); shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); expect(s.nations.a1.relations.b2).toBe('war'); expect(s.nations.b2.relations.a1).toBe('war'); }); it('is a no-op for an unknown nation id or an unknown cap', () => { const s = pair({ b2: 'hostile', a1: 'hostile' }); shiftRelationTowardPositive(s, 'a1', 'zzz', 'ally'); // zzz absent expect(s.nations.a1.relations.b2).toBe('hostile'); shiftRelationTowardPositive(s, 'a1', 'b2', 'nope' as RelationStatus); // bad cap expect(s.nations.a1.relations.b2).toBe('hostile'); }); }); // --------------------------------------------------------------------------- // applyMigrationOp // --------------------------------------------------------------------------- describe('applyMigrationOp', () => { it('a small migration: source loses pop+eco, destination gains pop+eco, relation unchanged (neutral→neutral)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }); // source: economy 70→69, population 50→49 (re-derived from its single region) expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); // destination: economy 30→31, population 60→61 expect(state.nations.b2.stats).toEqual(NS(40, 31, 50, 61)); // both regions mirrored expect(state.regionStats['a-r1']).toEqual(RS(80, 69, 60, 49)); expect(state.regionStats['b-r1']).toEqual(RS(40, 31, 50, 61)); // a neutral pair is already at the neutral cap: no relation change expect(state.nations.a1.relations.b2 ?? 'neutral').toBe('neutral'); expect(state.nations.b2.relations.a1 ?? 'neutral').toBe('neutral'); }); it('resolves `from` / `to` by name (not just id)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'migration', from: 'Alpha', to: 'Bravo', scale: 'moderate' }); // moderate: source pop -2 / eco -2; destination pop +2 / eco +2 expect(state.nations.a1.stats).toEqual(NS(80, 68, 60, 48)); expect(state.nations.b2.stats).toEqual(NS(40, 32, 50, 62)); }); it('a large influx strains the destination stability by 2', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); // destination: eco 30→33, pop 60→63, stability 50→48 (large influx strain) expect(state.nations.b2.stats).toEqual(NS(40, 33, 48, 63)); expect(state.regionStats['b-r1']).toEqual(RS(40, 33, 48, 63)); // source: eco 70→67, pop 50→46 expect(state.nations.a1.stats).toEqual(NS(80, 67, 60, 46)); }); it('a refugees flow nudges the relation toward ally (cap ally); migration/diaspora stop at neutral', () => { const mk = (rel: RelationStatus) => mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: rel }), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: rel }), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); // refugees: hostile → rival let s = mk('hostile'); applyMigrationOp(s, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'small' }); expect(s.nations.a1.relations.b2).toBe('rival'); // migration: hostile → rival (capped at neutral, one step only) s = mk('hostile'); applyMigrationOp(s, { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }); expect(s.nations.a1.relations.b2).toBe('rival'); // migration: rival → neutral (reaches cap) s = mk('rival'); applyMigrationOp(s, { kind: 'diaspora', from: 'a1', to: 'b2', scale: 'small' }); expect(s.nations.a1.relations.b2).toBe('neutral'); // refugees: neutral → ally s = mk('neutral'); applyMigrationOp(s, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'small' }); expect(s.nations.a1.relations.b2).toBe('ally'); }); it('never nudges a warring pair (a war is left untouched)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: 'war' }), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: 'war' }), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); expect(state.nations.a1.relations.b2).toBe('war'); expect(state.nations.b2.relations.a1).toBe('war'); // the stat effects still apply (a war does not block population movement) expect(state.nations.a1.stats).toEqual(NS(80, 67, 60, 46)); expect(state.nations.b2.stats).toEqual(NS(40, 33, 48, 63)); }); it('an exodus only affects the source: no destination, no relation nudge', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: 'rival' }), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: 'rival' }), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'exodus', from: 'a1', scale: 'small' }); // source dips expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); // destination untouched expect(state.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); expect(state.regionStats['b-r1']).toEqual(RS(40, 30, 50, 60)); // no relation nudge expect(state.nations.a1.relations.b2).toBe('rival'); }); it('a self-flow (from === to) is dropped entirely', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, { 'a-r1': RS(80, 70, 60, 50) }, ); applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'a1', scale: 'large' }); // no change at all expect(state.nations.a1.stats).toEqual(NS(80, 70, 60, 50)); expect(state.regionStats['a-r1']).toEqual(RS(80, 70, 60, 50)); }); it('an unknown or ambiguous source / destination drops the op', () => { const base = () => mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); // unknown source let s = base(); applyMigrationOp(s, { kind: 'migration', from: 'zzz', to: 'b2', scale: 'small' }); expect(s.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); // unknown destination s = base(); applyMigrationOp(s, { kind: 'migration', from: 'a1', to: 'zzz', scale: 'small' }); expect(s.nations.a1.stats).toEqual(NS(80, 70, 60, 50)); // ambiguous source (two nations share the name) const amb = mkState( { a1: mkNation('a1', 'Twin', NS(80, 70, 60, 50), ['a-r1']), a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), ['a-r2']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'a-r2': RS(20, 20, 20, 20), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(amb, { kind: 'migration', from: 'Twin', to: 'Bravo', scale: 'small' }); expect(amb.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); }); it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => { const state = mkState( { // a1 owns no regions — its stats are maintained directly, not derived a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), []), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'moderate' }); // source (0 regions) keeps the direct dip: eco 70→68, pop 50→48 expect(state.nations.a1.stats).toEqual(NS(80, 68, 60, 48)); // destination re-derived from its region expect(state.nations.b2.stats).toEqual(NS(40, 32, 50, 62)); }); it('never moves territory (sole ownership untouched)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); expect(state.nations.a1.territory).toEqual(['a-r1']); expect(state.nations.b2.territory).toEqual(['b-r1']); }); it('clamps affected stats to 0 and 100 (a source at the floor, a destination at the ceiling)', () => { const state = mkState( { // a1 at the population / economy floor a1: mkNation('a1', 'Alpha', NS(5, 3, 50, 1), ['a-r1']), // b2 near the ceiling: a large gain (+3) pushes it past 100 b2: mkNation('b2', 'Bravo', NS(90, 98, 50, 98), ['b-r1']), }, { 'a-r1': RS(5, 3, 50, 1), 'b-r1': RS(90, 98, 50, 98) }, ); applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'large' }); // source clamps at 0 (pop 1-4→0, eco 3-3→0) expect(state.nations.a1.stats.population).toBe(0); expect(state.nations.a1.stats.economy).toBe(0); // destination gains but clamps at 100 (pop 98+3→100, eco 98+3→100) expect(state.nations.b2.stats.population).toBe(100); expect(state.nations.b2.stats.economy).toBe(100); }); }); // --------------------------------------------------------------------------- // The Ability object contract // --------------------------------------------------------------------------- describe('migration ability', () => { it('owns the "migration" field; default() is a fresh empty { ops: [] }', () => { expect(migration.field).toBe('migration'); expect(migration.default()).toEqual({ ops: [] }); expect(migration.default()).not.toBe(migration.default()); }); it('default() is not meaningful; a populated value is', () => { expect(migration.isMeaningful(migration.default())).toBe(false); expect( migration.isMeaningful({ ops: [{ kind: 'exodus', from: 'a1', scale: 'small' }] }), ).toBe(true); }); it('validate() wraps the bare-list validator into the { ops } shape', () => { expect( migration.validate([{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }]), ).toEqual({ ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] }); expect(migration.validate(undefined)).toEqual({ ops: [] }); }); it('the stub value equals default() (the stub never proposes a migration)', () => { expect(migration.stub()).toEqual({ ops: [] }); }); it('payload() is always empty (turn-transient — no durable state to show)', () => { expect(migration.payload({} as never)).toEqual({}); }); it('stateFields is undefined (the layer owns no durable GameState field)', () => { expect(migration.stateFields).toBeUndefined(); }); it('apply() runs each validated op against the world (no throw on a bad ref)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), }, { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, ); const ctx = { previous: state, actingTurn: state.turn }; migration.apply( state, { ops: [ { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }, // an unknown source — must be dropped, not throw { kind: 'refugees', from: 'ghost', to: 'b2', scale: 'small' }, ], }, ctx, ); expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); expect(state.nations.b2.stats).toEqual(NS(40, 31, 50, 61)); }); });