feat(abilities): add migration & refugees lever (v0.42.0, #64)
CI / build + unit + e2e (pull_request) Successful in 2m12s
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:
@@ -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: () => ({}),
|
||||
};
|
||||
Reference in New Issue
Block a user