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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user