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
+41 -2
View File
@@ -102,8 +102,9 @@ history/
│ │ ├── shared.ts # shared helpers (stat dips / re-derive / ref resolution, …)
│ │ ├── apply-helpers.ts # shared apply-path helpers
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 9-ability contract)
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
│ │ │ # migration (v0.42.0 #64)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 10-ability contract)
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
│ └── *.test.ts # unit tests (vitest)
└── src/
@@ -509,6 +510,44 @@ history/
re-baselined in v0.41.0). `isMeaningful` is `events.length > 0`. The stub never proposes a
disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/abilities/disasters.test.ts`).
- **Migration & refugees — population flows between nations with a deterministic dip (v0.42.0, #64).**
`TurnResult.migration = MigrationOps = { ops: MigrationOp[] }` (required, default
`{ ops: [] }`); `MigrationOp = { kind, from, to?, scale, detail? }` with `kind ∈
MIGRATION_KINDS = migration | refugees | diaspora | exodus` and `scale ∈
MIGRATION_SCALES = small | moderate | large` (`to` is required for every kind except
`exodus`, which has no destination). Like the hybrid / disasters layers this is
**turn-transient**: it owns NO `GameState` field (no durable list, no `db.ts` change) — a
flow that persists is simply re-declared each turn it continues. The DETERMINISTIC
`MIGRATION_SOURCE_EFFECTS` / `MIGRATION_DESTINATION_EFFECTS` tables
(`server/abilities/migration.ts`) map each `scale` to signed stat deltas (no hidden
randomness): the source LOSES population + economy (small −1/−1, moderate −2/−2,
large −4/−3) and the destination GAINS population + economy (small +1/+1, moderate
+2/+2, large +3/+3); a `large` influx additionally strains the destination's stability by
`LARGE_INFLUX_STABILITY_DIP = -2`. Neither side ever moves territory (sole ownership is
untouched) and none touch borders. Both `from` and `to` resolve by the shared
`resolveNationRef` (id first, then a case-insensitive unique name): an unknown / ambiguous
source, a missing / unknown / ambiguous destination, or a **self-flow** (`from` and `to`
the same nation) drops the WHOLE op (the source is untouched); `exodus` skips the
destination entirely. The from↔to relation then moves one step toward the positive end of
the ally/neutral/rival/hostile ladder via the self-contained `shiftRelationTowardPositive`
(the mirror of the hybrid layer's `shiftRelationTowardHostile`): capped at `ally` for
`refugees` (hosting refugees builds the strongest goodwill), `neutral` for `migration` /
`diaspora`, and not at all for `exodus`; a missing relation starts at `neutral` and a
`war` relation is NEVER touched (the nudge neither fabricates nor escalates a war).
`applyMigrationOp` (run in the `ABILITIES` loop after territory / wars / economy, before
markers) applies the source dip, then the destination dip (+ stability strain for `large`),
clamps both to 0–100, mirrors each to every region the nation owns (`regionStats`), and
re-derives each nation's aggregate `stats` (the same 0-region / no-entry guards, so a
0-region nation keeps its directly-mutated value and the effect persists). `payload()` is
`{}` (turn-transient), so the user-prompt golden (`user.txt`) is unchanged; the system
prompt gains the field's schema line + constraint bullet (the system goldens were
re-baselined in v0.42.0). `isMeaningful` is `ops.length > 0`. The stub never proposes a
migration (`{ ops: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is
unit-tested only (`server/abilities/migration.test.ts`). **Design note:** the Gitea #64
body sketched a simpler `flows: {type: 'refugees'|'settlers'}` shape with a settler
re-claim of no-man's-land; the implemented, approved design is this richer four-kind
nation-to-nation **stat** layer that deliberately never moves territory (a no-man's-land
re-claim stays the `regionChanges` lever's job).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was