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
+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();