CI / build + unit + e2e (pull_request) Successful in 2m33s
Adds the sea ability: a fixed set of 12 named sea zones with sole-control semantics, contest-by-higher-military, and a deterministic per-turn +1 military/+1 economy bonus for each claimed zone. Durable (owns GameState.seaZones), additive migration, stub-inert (e2e unaffected). Fixes #65 Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented approved design is this richer 'sea' layer with a fixed zone set.
573 lines
24 KiB
TypeScript
573 lines
24 KiB
TypeScript
// 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<string, RelationStatus> = {},
|
|
): 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<string, Nation>,
|
|
regionStats: Record<string, RegionStats>,
|
|
): 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<string, RelationStatus>): 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));
|
|
});
|
|
});
|