feat(abilities): add migration & refugees lever (v0.42.0, #64)
CI / build + unit + e2e (pull_request) Successful in 2m12s

Add the 10th modular LLM ability: a turn-transient TurnResult.migration
lever declaring population flows between nations (migration / refugees /
diaspora / exodus). The source loses a little population + economy, the
destination gains a little (a large influx also strains its stability),
and the from<->to relation is nudged one step toward the positive end of
the ally/neutral/rival/hostile ladder (capped by kind; a war is never
touched). Deterministic per-scale tables, no hidden randomness, clamped
0-100, mirrored to owned regions, re-derived aggregates; never moves
territory. Registered last in ABILITIES; user prompt unchanged
(user.txt golden byte-identical), system goldens re-baselined. Fixes #64.
This commit is contained in:
2026-10-16 10:15:45 +00:00
parent e0f3d0313b
commit 7e4faa73d3
13 changed files with 1142 additions and 55 deletions
@@ -40,6 +40,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -66,6 +67,7 @@ HARD CONSTRAINTS
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+2
View File
@@ -40,6 +40,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -66,6 +67,7 @@ HARD CONSTRAINTS
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none).
- Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"<nation id or name>","to":"<new form of government>"}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none).
- Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event).
- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).
- Prefer 1-4 events per turn; keep each text 1-3 sentences.
- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.
- Keep every stat value between 0 and 100.
+23 -4
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 9 per-feature LLM abilities.
// `ABILITIES` is the single source of truth for the 10 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration
// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
@@ -23,13 +23,16 @@ import { nuclear } from './nuclear';
import { leaders } from './leaders';
import { ideology } from './ideology';
import { disasters } from './disasters';
import { migration } from './migration';
import type { Ability, AbilityField } from './types';
/**
* The 9 per-feature LLM abilities in their canonical apply order.
* The 10 per-feature LLM abilities in their canonical apply order.
* The order matters: it determines the order of the schema lines in the
* system prompt, the order of the validate calls, the order of the apply
* steps, and the order of the payload fields in the user prompt.
* steps, and the order of the payload fields in the user prompt. New
* abilities are APPENDED last — never reorder existing entries (their order
* is pinned by the goldens and the stub's byte-identical output).
*/
export const ABILITIES: readonly Ability<AbilityField>[] = [
takeovers,
@@ -41,6 +44,7 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
leaders,
ideology,
disasters,
migration,
];
// ── type re-exports ────────────────────────────────────────────────────────────
@@ -139,3 +143,18 @@ export {
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
} from './disasters';
// ── migration ────────────────────────────────────────────────────────────────
export {
validateMigrationOps,
applyMigrationOp,
shiftRelationTowardPositive,
isMigrationKind,
isMigrationScale,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
} from './migration';
+570
View File
@@ -0,0 +1,570 @@
// 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: [],
};
}
// ---------------------------------------------------------------------------
// 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));
});
});
+325
View File
@@ -0,0 +1,325 @@
// The MIGRATION & REFUGEES ability (v0.42.0, #64) as a modular LLM-ability
// (v0.40.0, #67). People moving between nations each turn: ordinary migration,
// refugee flows (fleeing a war or disaster), a spreading diaspora, and an
// exodus (people leaving with no destination). Unlike most other abilities this
// one is **turn-transient**: it owns NO `GameState` field (no durable list, no
// `db.ts` change) — exactly like the hybrid and disasters layers. Each op
// carries a `kind`, a source `from` (the nation people leave), a destination
// `to` (required for every kind except `exodus`), and a `scale` (small /
// moderate / large). The engine applies a DETERMINISTIC effect chosen from the
// per-`scale` tables below (no hidden randomness — the LLM chooses the kind and
// scale, the tables decide the magnitude): the source LOSES a small amount of
// population and economy, the destination GAINS a small amount of population and
// economy (a `large` influx additionally strains the destination's stability),
// both clamped to 0–100, mirrored to each side's regions (`regionStats`), and
// each side's derived `stats` re-derived afterwards (the same 0-region /
// no-entry guards as hybrid / economy / disasters, so a 0-region nation keeps
// its directly-mutated value and the effect persists to the next turn). The
// from↔to relation then moves one step toward the positive end of the
// ally/neutral/rival/hostile ladder (`shiftRelationTowardPositive`) — capped at
// `ally` for `refugees` (hosting refugees builds the strongest goodwill),
// `neutral` for `migration` / `diaspora` (milder ties), and not at all for
// `exodus`; a `war` relation is never touched (the nudge neither fabricates nor
// escalates a war). Both `from` and `to` are resolved by the shared
// `resolveNationRef` (id first, then a case-insensitive unique name); an
// unknown / ambiguous ref, a missing `to` (for a non-`exodus` kind), or a
// self-flow (`from` and `to` the same nation) drops the whole op. It never
// moves territory (sole ownership is untouched).
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type { MigrationOp, MigrationScale, MigrationKind } from '../../shared/protocol';
import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/** The four kinds of population flow a turn can declare (v0.42.0, #64). */
export const MIGRATION_KINDS: readonly MigrationKind[] = [
'migration',
'refugees',
'diaspora',
'exodus',
];
/** The three flow sizes a migration op can carry (v0.42.0, #64). */
export const MIGRATION_SCALES: readonly MigrationScale[] = ['small', 'moderate', 'large'];
const VALID_KINDS = new Set<string>(MIGRATION_KINDS);
const VALID_SCALES = new Set<string>(MIGRATION_SCALES);
/** True when `k` is one of the four known migration kinds (v0.42.0, #64). */
export function isMigrationKind(k: unknown): k is MigrationKind {
return typeof k === 'string' && VALID_KINDS.has(k);
}
/** True when `s` is one of the three known migration scales (v0.42.0, #64). */
export function isMigrationScale(s: unknown): s is MigrationScale {
return typeof s === 'string' && VALID_SCALES.has(s);
}
/**
* (v0.42.0, #64) The DETERMINISTIC per-`scale` stat dip applied to the SOURCE
* nation of a migration flow (the nation people leave). Each scale maps to a
* small set of signed (negative) stat deltas: the source loses population and
* economy. No hidden randomness — the LLM picks the scale, the table decides
* the magnitude. Military and stability are untouched by the source dip.
*/
export const MIGRATION_SOURCE_EFFECTS: Record<MigrationScale, Partial<Record<StatKey, number>>> = {
small: { population: -1, economy: -1 },
moderate: { population: -2, economy: -2 },
large: { population: -4, economy: -3 },
};
/**
* (v0.42.0, #64) The DETERMINISTIC per-`scale` stat gain applied to the
* DESTINATION nation of a migration flow (the nation people arrive in). Each
* scale maps to a small set of signed (positive) stat deltas: the destination
* gains population and economy. No hidden randomness. Military and stability
* are untouched by the base gain (the `large`-scale stability strain is applied
* separately, see {@link LARGE_INFLUX_STABILITY_DIP}).
*/
export const MIGRATION_DESTINATION_EFFECTS: Record<MigrationScale, Partial<Record<StatKey, number>>> =
{
small: { population: 1, economy: 1 },
moderate: { population: 2, economy: 2 },
large: { population: 3, economy: 3 },
};
/**
* (v0.42.0, #64) The extra STABILITY dip applied to the destination of a
* `large`-scale influx only — a big population surge strains a society even as
* it grows. Added on top of the base destination gain.
*/
export const LARGE_INFLUX_STABILITY_DIP = -2;
/**
* The positive-relations ladder, strongest (most positive) to weakest
* (least positive) — the same four non-war statuses, in the order a relation
* moves when it is nudged TOWARD the positive end. A missing relation is
* treated as `neutral`; `war` is never touched (a war is its own entity — a
* population flow neither fabricates nor escalates one). The `cap` in
* {@link shiftRelationTowardPositive} is a member of this array.
*/
const POSITIVE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
/**
* (v0.42.0, #64) Nudge the bilateral relation between nations `a` and `b`
* ONE step toward the POSITIVE end (toward `ally`) of the
* {@link POSITIVE_ORDER} ladder, applied symmetrically to both directions, but
* never past the given `cap`. A missing relation starts at `neutral`; a `war`
* relation is never touched (a population flow neither fabricates nor
* escalates a war — a warring pair is left exactly as it is). Mutates `state`
* in place (the caller passes a clone — applyTurnResult does). Never throws.
*
* `cap` is the most-positive status the nudge may reach:
* - `refugees` → `ally` (hosting refugees builds the strongest goodwill)
* - `migration` / `diaspora` → `neutral` (milder ties)
* - `exodus` → no nudge at all
*/
export function shiftRelationTowardPositive(
state: GameState,
a: string,
b: string,
cap: RelationStatus,
): void {
const na = state.nations[a];
const nb = state.nations[b];
if (!na || !nb) return;
const capIdx = POSITIVE_ORDER.indexOf(cap);
if (capIdx === -1) return; // unknown cap: no-op (defensive)
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
if (cur === 'war') return null; // never touch a war relation
const s = cur ?? 'neutral'; // a missing relation is neutral
const i = POSITIVE_ORDER.indexOf(s);
if (i === -1) return 'neutral'; // defensive: unknown status
if (i <= capIdx) return s; // already at or more positive than the cap: no change
return POSITIVE_ORDER[i - 1]; // one step toward the positive end
};
const stepA = step(na.relations[b]);
if (stepA !== null) na.relations[b] = stepA;
const stepB = step(nb.relations[a]);
if (stepB !== null) nb.relations[a] = stepB;
}
/**
* (v0.42.0, #64) Validate the raw `migration` field of a turn into a safe
* list of {@link MigrationOp}s. Defensive like the rest of the parser — never
* throws. The raw value may be the object `{ ops: [...] }` (the TurnResult
* shape) or a bare array (be lenient about either). Each op needs a known
* `kind` (one of {@link MIGRATION_KINDS}), a known `scale` (one of
* {@link MIGRATION_SCALES}), a non-empty `from` (the source nation, capped at
* 200 chars), and — for every kind except `exodus` — a non-empty `to`
* (the destination nation, capped at 200 chars); a non-empty `detail` is capped
* at 300 chars. An op with an unknown / missing `kind`, an unknown / missing
* `scale`, an empty `from`, or (for a non-`exodus` kind) an empty `to` is
* dropped. Resolution of `from` / `to` to concrete nation ids happens at apply
* time (`applyMigrationOp`), not here. A missing / malformed top-level value
* yields `{ ops: [] }`.
*/
export function validateMigrationOps(raw: unknown): MigrationOp[] {
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).ops;
if (Array.isArray(inner)) list = inner;
}
if (!list) return [];
const out: MigrationOp[] = [];
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isMigrationKind(o.kind)) continue; // unknown / missing kind: drop
if (!isMigrationScale(o.scale)) continue; // unknown / missing scale: drop
const from = str(o.from, 200);
if (!from) continue; // empty / missing source: drop
const detail = str(o.detail, 300);
if (o.kind === 'exodus') {
// an exodus has no destination — `to` is optional (and ignored)
out.push({
kind: o.kind,
from,
scale: o.scale,
...(detail ? { detail } : {}),
});
} else {
const to = str(o.to, 200);
if (!to) continue; // non-exodus kind needs a destination: drop
out.push({
kind: o.kind,
from,
to,
scale: o.scale,
...(detail ? { detail } : {}),
});
}
}
return out;
}
/**
* Apply a set of signed stat `dips` to a nation's `stats` AND to every region
* it owns that has a `regionStats` entry (clamped to 0–100), then re-derive
* the nation's aggregate `stats` as the area×population-weighted median of its
* regions — the same 0-region / no-entry guards as the hybrid / economy /
* disasters layers, so a 0-region nation keeps its directly-mutated value and
* the effect persists to the next turn. Mutates in place; never throws.
*/
function applyNationDips(
nation: Nation,
dips: Partial<Record<StatKey, number>>,
regionStats: GameState['regionStats'],
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') nation.stats[k] = clampStat(nation.stats[k] + d);
});
for (const key of nation.territory) {
const rs = regionStats[key];
if (!rs) continue; // a key with no entry is skipped
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') rs[k] = clampStat(rs[k] + d);
});
}
// Re-derive the nation's aggregate stats so the effect persists (the main
// recompute ran earlier this turn; without this the change would be washed
// out next turn). A nation with no regionStats entry keeps its
// directly-mutated `stats`.
if (nation.territory.length > 0 && nation.territory.some((k) => regionStats[k])) {
nation.stats = deriveNationStats(nation.territory, regionStats, geoOf);
}
}
/**
* (v0.42.0, #64) Apply ONE migration / refugee op to the (cloned) `next` state,
* mutating it in place and never throwing. Resolution order:
* 1. `from` must resolve to a nation (id first, then a unique name); an
* unknown / ambiguous source drops the op.
* 2. For every kind except `exodus`, `to` must resolve to a nation distinct
* from `from` (a missing / unknown / ambiguous destination, or a
* self-flow, drops the op); `exodus` has no destination and skips this.
* Then the deterministic per-`scale` effects from
* {@link MIGRATION_SOURCE_EFFECTS} / {@link MIGRATION_DESTINATION_EFFECTS}
* are applied:
* - the SOURCE nation loses population and economy (all four kinds,
* including `exodus`) — for a non-`exodus` op this is applied only AFTER
* the destination is validated, so a self-flow or a bad destination drops
* the op entirely and never touches the source;
* - for a non-`exodus` kind the DESTINATION gains population and economy,
* and a `large` influx additionally strains its stability by
* {@link LARGE_INFLUX_STABILITY_DIP};
* - each affected nation's derived `stats` are re-derived afterwards (the
* same 0-region / no-entry guards, so the effect persists);
* - for a non-`exodus` kind the from↔to relation moves one step toward the
* positive end of the ladder (cap `ally` for `refugees`, `neutral` for
* `migration` / `diaspora`; a `war` relation is left untouched).
* The op never moves territory (sole ownership is untouched) and never creates
* or escalates a war.
*/
export function applyMigrationOp(
next: GameState,
op: MigrationOp,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
const from = resolveNationRef(next, op.from);
if (from === null) return; // unknown / ambiguous source: drop
const source = next.nations[from];
if (!source) return; // defensive: resolved id has no nation record
if (op.kind === 'exodus') {
// An exodus has no destination: the source loses population and economy
// and that is all (no gain, no relation nudge).
applyNationDips(source, MIGRATION_SOURCE_EFFECTS[op.scale], next.regionStats, geoOf);
return;
}
// Non-exodus: resolve the destination FIRST and drop the WHOLE op (including
// the source dip) when it is missing / unknown / ambiguous or a self-flow —
// so a self-flow or a bad destination never touches the source.
const to = op.to;
if (!to) return; // defensive: a non-exodus op should always have a `to`
const destId = resolveNationRef(next, to);
if (destId === null || destId === from) return; // unknown / self destination: drop
const dest = next.nations[destId];
if (!dest) return; // defensive
// The source loses population and economy (every non-exodus kind reaches
// here; the exodus path already applied its source dip and returned).
applyNationDips(source, MIGRATION_SOURCE_EFFECTS[op.scale], next.regionStats, geoOf);
// The destination gains population and economy; a large influx also strains
// its stability.
const destDips: Partial<Record<StatKey, number>> = { ...MIGRATION_DESTINATION_EFFECTS[op.scale] };
if (op.scale === 'large') {
destDips.stability = (destDips.stability ?? 0) + LARGE_INFLUX_STABILITY_DIP;
}
applyNationDips(dest, destDips, next.regionStats, geoOf);
// The from↔to relation moves one step toward the positive end, capped by
// kind (a `war` relation is left untouched by shiftRelationTowardPositive).
const cap: RelationStatus = op.kind === 'refugees' ? 'ally' : 'neutral';
shiftRelationTowardPositive(next, from, destId, cap);
}
export const migration: Ability<'migration'> = {
field: 'migration',
default: () => ({ ops: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ ops }` object (mirrors the historical TurnResult shape).
validate: (raw) => ({ ops: validateMigrationOps(raw) }),
apply: (state, data, ctx) => {
for (const op of data.ops) {
applyMigrationOp(state, op, ctx.geoOf);
}
},
schemaLine: () =>
' "migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},',
constraint: () =>
'- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination\'s stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).',
isMeaningful: (data) => data.ops.length > 0,
stub: () => ({ ops: [] }),
// The layer is turn-transient — it contributes nothing to the user payload
// (no durable state to show the model), so the payload is always empty.
payload: () => ({}),
};
+34 -9
View File
@@ -12,7 +12,7 @@
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 9 abilities; each ability's default() equals the canonical
// list of the 10 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended
@@ -31,6 +31,7 @@ import {
activeWarPairs,
applyDisasterEvent,
applyIndustryEffects,
applyMigrationOp,
atWar,
buildSystemPrompt,
buildUserPrompt,
@@ -44,10 +45,18 @@ import {
isDisasterType,
isHybridOpType,
isIndustryType,
isMigrationKind,
isMigrationScale,
LARGE_INFLUX_STABILITY_DIP,
MIGRATION_DESTINATION_EFFECTS,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
shiftRelationTowardHostile,
shiftRelationTowardPositive,
syncWarRelations,
validateDisasterEvents,
validateEconomyOps,
@@ -55,6 +64,7 @@ import {
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateMigrationOps,
validateNuclearOps,
validateTakeovers,
validateWarOps,
@@ -217,6 +227,7 @@ function fullDefaultTurn(): TurnResult {
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
markers: { add: [], remove: [] },
};
}
@@ -242,11 +253,13 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { changes: [{ target: 'a1', to: 'democracy' }] };
case 'disasters':
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
case 'migration':
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 9-ability order (takeovers → disasters)', () => {
it('is the canonical 10-ability order (takeovers → migration)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -257,13 +270,14 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'leaders',
'ideology',
'disasters',
'migration',
]);
});
it('lists 9 distinct fields, each a key of TurnResult', () => {
it('lists 10 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(9);
expect(new Set(fields).size).toBe(9);
expect(fields).toHaveLength(10);
expect(new Set(fields).size).toBe(10);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -328,7 +342,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => {
// The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when a 10th ability is appended.
// constraint bullet — must keep working when an 11th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
@@ -347,9 +361,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(10);
expect(extended.map((a) => a.constraint())).toHaveLength(10);
expect(extended[9].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(11);
expect(extended.map((a) => a.constraint())).toHaveLength(11);
expect(extended[10].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -388,6 +402,17 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
// migration (v0.42.0, #64)
validateMigrationOps,
applyMigrationOp,
shiftRelationTowardPositive,
isMigrationKind,
isMigrationScale,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
+2 -1
View File
@@ -32,7 +32,8 @@ export type AbilityField =
| 'nuclear'
| 'leaders'
| 'ideology'
| 'disasters';
| 'disasters'
| 'migration';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
+39 -38
View File
@@ -570,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -592,7 +592,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -613,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -632,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1089,7 +1089,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1106,7 +1106,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1129,7 +1129,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1155,7 +1155,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1173,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1263,7 +1263,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1378,7 +1378,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1685,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1700,7 +1700,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1721,7 +1721,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1742,7 +1742,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1765,7 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1798,7 +1798,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1822,7 +1822,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1849,7 +1849,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1875,7 +1875,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1901,7 +1901,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1925,7 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1947,7 +1947,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1981,7 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2005,13 +2005,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2038,7 +2038,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2085,7 +2085,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2110,7 +2110,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2126,14 +2126,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2203,7 +2203,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2300,6 +2300,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
leaders: partial.leaders ?? { changes: [] },
ideology: partial.ideology ?? { changes: [] },
disasters: partial.disasters ?? { events: [] },
migration: partial.migration ?? { ops: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3025,7 +3026,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3413,7 +3414,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3434,7 +3435,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3455,7 +3456,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] },
}),
).toBe(false);
});
+11
View File
@@ -94,6 +94,16 @@ export {
DISASTER_TYPES,
DISASTER_SEVERITIES,
DISASTER_EFFECTS,
validateMigrationOps,
applyMigrationOp,
shiftRelationTowardPositive,
isMigrationKind,
isMigrationScale,
MIGRATION_KINDS,
MIGRATION_SCALES,
MIGRATION_SOURCE_EFFECTS,
MIGRATION_DESTINATION_EFFECTS,
LARGE_INFLUX_STABILITY_DIP,
} from './abilities';
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -622,6 +632,7 @@ export function validateTurnResult(
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
markers: { add: [], remove: [] },
};