Files
History/server/abilities/migration.ts
kamilkrzywda 7e4faa73d3
CI / build + unit + e2e (pull_request) Successful in 2m12s
feat(abilities): add migration & refugees lever (v0.42.0, #64)
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.
2026-10-16 10:15:45 +00:00

326 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: () => ({}),
};