From 7e4faa73d3de9215a8f03474b5614a6c69519304 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 16 Oct 2026 10:15:45 +0000 Subject: [PATCH] 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. --- AGENTS.md | 43 +- CHANGELOG.md | 12 + package.json | 2 +- server/abilities/goldens/system-no-tool.txt | 2 + server/abilities/goldens/system-tool.txt | 2 + server/abilities/index.ts | 27 +- server/abilities/migration.test.ts | 570 ++++++++++++++++++++ server/abilities/migration.ts | 325 +++++++++++ server/abilities/registry.test.ts | 43 +- server/abilities/types.ts | 3 +- server/engine.test.ts | 77 +-- server/engine.ts | 11 + shared/protocol.ts | 80 +++ 13 files changed, 1142 insertions(+), 55 deletions(-) create mode 100644 server/abilities/migration.test.ts create mode 100644 server/abilities/migration.ts diff --git a/AGENTS.md b/AGENTS.md index e8c6d5b..fbbf424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,8 +102,9 @@ history/ │ │ ├── shared.ts # shared helpers (stat dips / re-derive / ref resolution, …) │ │ ├── apply-helpers.ts # shared apply-path helpers │ │ ├── .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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0771ecb..8b06945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to **History** are listed here, newest first. +## v0.42.0 — 2026-10-16 10:12 +- **#64: Migration & refugees — a `TurnResult.migration` lever letting the LLM declare population flows between nations (ordinary migration / refugee flows / a spreading diaspora / an exodus), with a deterministic per-`scale` effect: the source loses a little population and 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 ladder.** It is a **turn-transient** feature, exactly like the v0.34 `hybrid` and v0.41 `disasters` layers: **no new `GameState` field, no `db.ts` change, no durable list** — the ops are consumed inside `applyTurnResult` and leave nothing behind. It lands as the **10th ability** of the v0.40.0 modular system, in a new `server/abilities/migration.ts` module, **registered last** in the `ABILITIES` registry (after `disasters`). + - **Types (`shared/protocol.ts`).** New `MigrationKind` (`'migration' | 'refugees' | 'diaspora' | 'exodus'`), `MigrationScale` (`'small' | 'moderate' | 'large'`), `MigrationOp` (`{ kind; from; to?; scale; detail? }` — `to` is required for every kind except `exodus`, which has no destination), and `MigrationOps` (`{ ops: MigrationOp[] }`); `TurnResult.migration` is **required**, placed between `disasters` and `markers`, default `{ ops: [] }`. + - **Ability module (`server/abilities/migration.ts`).** `field: 'migration'`; `default()` / `stub()` → a **fresh** `{ ops: [] }` each call; `validate` wraps the exported `validateMigrationOps` (defensive, never throws — accepts the `{ ops: [...] }` object or a bare array; each op needs a valid `kind`, a valid `scale`, a non-empty `from` (trimmed, ≤200), and — for every kind except `exodus` — a non-empty `to` (trimmed, ≤200); an optional `detail` (trimmed, ≤300, empty dropped) — anything else is dropped); `apply` runs `applyMigrationOp` per op; `schemaLine()` / `constraint()` supply the prompt text; `payload()` → `{}` (no user-payload keys — both refs resolve from the existing nation ref table); `isMeaningful` = `ops.length > 0`; **no** `stateFields` (turn-transient, no durable `GameState` field). + - **Deterministic effects (tables, no hidden randomness).** `MIGRATION_SOURCE_EFFECTS` per scale — the source LOSES: small `{population:-1, economy:-1}`, moderate `{population:-2, economy:-2}`, large `{population:-4, economy:-3}`. `MIGRATION_DESTINATION_EFFECTS` per scale — the destination GAINS: small `{population:+1, economy:+1}`, moderate `{population:+2, economy:+2}`, large `{population:+3, economy:+3}`. `LARGE_INFLUX_STABILITY_DIP = -2` — a `large` influx additionally strains the destination's stability by 2. All clamped to 0–100. + - **Apply semantics (`applyMigrationOp`).** Resolution is drop-first: `from` must resolve to a nation (id first, then a unique name; unknown / ambiguous → drop); for every kind except `exodus` the `to` must resolve to a nation **distinct from** `from` (missing / unknown / ambiguous / self-flow → the **whole op** is dropped, the source untouched). Only then is the source dip applied (all four kinds, including `exodus`); `exodus` then returns (no destination, no nudge). Otherwise the destination gain is applied (plus the `large` stability strain), and the from↔to relation is nudged by the self-contained `shiftRelationTowardPositive` — the mirror of the hybrid layer's `shiftRelationTowardHostile`, but toward `ally` (`POSITIVE_ORDER = ['ally','neutral','rival','hostile']`, cap `ally` for `refugees`, `neutral` for `migration` / `diaspora`, none for `exodus`; a missing relation starts at `neutral`; a `war` relation is **never** touched — the nudge neither fabricates nor escalates a war). Each affected nation's dip is clamped to 0–100, **mirrored to every region it owns** (`regionStats`), and its derived `stats` **re-derived** afterwards (the same 0-region / no-entry guards as hybrid / economy / disasters — a 0-region nation keeps its directly-mutated value and the effect persists). It **never moves territory** (sole ownership is untouched). The **stub never** proposes a migration (`{ ops: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only. + - **Registry & prompt.** `migration` is added **last** in `ABILITIES` (existing order untouched); the system prompt gains the `migration` schema line and its constraint bullet ("at most 1–2 per turn, each narrated in `events` as a `social` or `diplomatic` event"); the user prompt is unchanged (`payload()` is `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `migration` schema line + constraint bullet); the **`user.txt` golden stays byte-identical** (no new user-payload keys — verified by checksum). + - **Tests.** New `server/abilities/migration.test.ts` (**38 tests**): the kind/scale predicate sets; the three effect tables (exact keys / signs / monotonicity); `validateMigrationOps` (shape / leniency / drops / field caps); `shiftRelationTowardPositive` (one-step nudge, caps, war-untouched, missing→neutral, unknown no-op); `applyMigrationOp` (small / moderate / large source & destination dips, the `large` stability strain, name-based resolve, the relation nudge by kind, war-untouched, exodus-only-source, self-flow drop, unknown / ambiguous source & destination drop, 0-region direct-dip persistence, territory-untouched, clamping at 0 and 100); and the ability const contract (field, no `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload() {}`, `validate` wrapping, `apply` ctx wiring). `registry.test.ts` is updated for the **10-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, re-export pin); the goldens are regenerated (verified idempotent). + - **Design note (interpretation of the issue body).** The Gitea #64 body sketched a simpler `flows: { type: 'refugees' | 'settlers' }` shape that included a settler **re-claim of no-man's-land**; the refined, approved design implemented here is the richer `migration.ops` union of **four nation-to-nation population-flow kinds** (migration / refugees / diaspora / exodus) — a purely **stat** layer that **never moves territory** (sole ownership stays untouched; a no-man's-land re-claim is deliberately out of scope, as territory movement remains the `regionChanges` lever's job). +- **Guarantees.** No client (`src/`) changes, no new runtime deps, `shared/` keeps zero runtime deps, and no `@turf`/`@types/geojson` in any `.ts`. All unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is migration-inert). +- **Version** bumped `0.41.0 → 0.42.0`. + ## v0.41.0 — 2026-10-16 09:07 - **#63: Disasters & pandemics — a `TurnResult.disasters` lever letting the LLM declare natural/man-made disasters (earthquake / flood / volcanic / wildfire / famine / plague / epidemic) on a nation or a region, with a deterministic stat dip (stability / economy / military).** It is a **turn-transient** feature, exactly like the v0.34 `hybrid` layer: **no new `GameState` field, no `db.ts` change, no durable list** — the ops are consumed inside `applyTurnResult` and leave nothing behind. It lands as the **9th ability** of the v0.40.0 modular system, in a new `server/abilities/disasters.ts` module, **registered last** in the `ABILITIES` registry (after `ideology`, before the loop appends `markers` / `nationOps`). - **Types (`shared/protocol.ts`).** New `DisasterType` (`'earthquake' | 'flood' | 'volcanic' | 'wildfire' | 'famine' | 'plague' | 'epidemic'`), `DisasterSeverity` (`'minor' | 'moderate' | 'severe'`), `DisasterEvent` (`{ type; target; severity; detail? }` — `target` is a nation ref **or** a region key), and `DisastersOps` (`{ events: DisasterEvent[] }`); `TurnResult.disasters` is **required**, placed between `ideology` and `markers`, default `{ events: [] }`. diff --git a/package.json b/package.json index 23974c7..0217510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.41.0", + "version": "0.42.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/abilities/goldens/system-no-tool.txt b/server/abilities/goldens/system-no-tool.txt index 6efcc56..2da22a6 100644 --- a/server/abilities/goldens/system-no-tool.txt +++ b/server/abilities/goldens/system-no-tool.txt @@ -40,6 +40,7 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen "leaders": {"changes":[{"target":"","name":"","cause":""}]}, "ideology": {"changes":[{"target":"","to":""}]}, "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, + "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -66,6 +67,7 @@ HARD CONSTRAINTS - Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none). - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). - Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event). +- 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). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/system-tool.txt b/server/abilities/goldens/system-tool.txt index 89608ac..dcb5a56 100644 --- a/server/abilities/goldens/system-tool.txt +++ b/server/abilities/goldens/system-tool.txt @@ -40,6 +40,7 @@ You have a getNeighbours tool: given region keys it returns the region keys that "leaders": {"changes":[{"target":"","name":"","cause":""}]}, "ideology": {"changes":[{"target":"","to":""}]}, "disasters": {"events":[{"type":"","target":"","severity":"","detail":""}]}, + "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -66,6 +67,7 @@ HARD CONSTRAINTS - Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message's "nations" field lists each nation's current "leader" (omitted when a nation has none). - Ideology ("ideology") is each nation's form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation's ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message's "nations" field lists each nation's current "ideology" (omitted when a nation has none). - Disasters & pandemics ("disasters.events") are natural and man-made catastrophes — earthquakes, floods, volcanic eruptions, wildfires, famines, plagues, and epidemics — that hit a nation or a single region this turn. Each event names a "target" (a nation id / name, or a region key that hits that region's SOLE owner — a region with 0 or 2+ owners can't be hit) and a "severity" (minor / moderate / severe); the engine applies a small, DETERMINISTIC stat dip from the (type, severity) table (a severe plague is the hardest hit; none of them touch population, and none move borders — sole ownership is untouched). There is NO memory of past disasters (the layer is turn-transient): a plague or epidemic that persists is simply RE-DECLARED each turn it continues, with a fresh "severity". Propose at most 1-2 events per turn and narrate each one in "events" (a "disaster" event). +- 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). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/index.ts b/server/abilities/index.ts index 3c103c9..c913c12 100644 --- a/server/abilities/index.ts +++ b/server/abilities/index.ts @@ -1,8 +1,8 @@ // The ordered LLM-ability registry. // -// `ABILITIES` is the single source of truth for the 9 per-feature LLM abilities. +// `ABILITIES` is the single source of truth for the 10 per-feature LLM abilities. // The engine iterates it in this order: -// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters +// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration // for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability // validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt, // (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check, @@ -23,13 +23,16 @@ import { nuclear } from './nuclear'; import { leaders } from './leaders'; import { ideology } from './ideology'; import { disasters } from './disasters'; +import { migration } from './migration'; import type { Ability, AbilityField } from './types'; /** - * The 9 per-feature LLM abilities in their canonical apply order. + * The 10 per-feature LLM abilities in their canonical apply order. * The order matters: it determines the order of the schema lines in the * system prompt, the order of the validate calls, the order of the apply - * steps, and the order of the payload fields in the user prompt. + * steps, and the order of the payload fields in the user prompt. New + * abilities are APPENDED last — never reorder existing entries (their order + * is pinned by the goldens and the stub's byte-identical output). */ export const ABILITIES: readonly Ability[] = [ takeovers, @@ -41,6 +44,7 @@ export const ABILITIES: readonly Ability[] = [ leaders, ideology, disasters, + migration, ]; // ── type re-exports ──────────────────────────────────────────────────────────── @@ -139,3 +143,18 @@ export { DISASTER_SEVERITIES, DISASTER_EFFECTS, } from './disasters'; + +// ── migration ──────────────────────────────────────────────────────────────── + +export { + validateMigrationOps, + applyMigrationOp, + shiftRelationTowardPositive, + isMigrationKind, + isMigrationScale, + MIGRATION_KINDS, + MIGRATION_SCALES, + MIGRATION_SOURCE_EFFECTS, + MIGRATION_DESTINATION_EFFECTS, + LARGE_INFLUX_STABILITY_DIP, +} from './migration'; diff --git a/server/abilities/migration.test.ts b/server/abilities/migration.test.ts new file mode 100644 index 0000000..fbdb0ec --- /dev/null +++ b/server/abilities/migration.test.ts @@ -0,0 +1,570 @@ +// Unit tests for the MIGRATION & REFUGEES ability (v0.42.0, #64) — the +// modular, turn-transient LLM-ability that moves population between nations: +// the source loses a small amount of population and economy, the destination +// gains a small amount (a `large` influx also strains the destination's +// stability), each side's aggregate stats are re-derived, and the from↔to +// relation is nudged one step toward the positive end of the ladder (capped by +// kind; a war is never touched). This is the ability's own test file (the +// registry's cross-cutting guarantees — order, distinct fields, the +// system/user goldens — are covered by ./registry.test.ts). The stub never +// proposes a migration, so the apply path is exercised here, not by the e2e +// suite. + +import { describe, expect, it } from 'vitest'; + +import { + LARGE_INFLUX_STABILITY_DIP, + MIGRATION_DESTINATION_EFFECTS, + MIGRATION_KINDS, + MIGRATION_SCALES, + MIGRATION_SOURCE_EFFECTS, + applyMigrationOp, + isMigrationKind, + isMigrationScale, + migration, + shiftRelationTowardPositive, + validateMigrationOps, +} from './migration'; + +import type { MigrationScale } from '../../shared/protocol'; +import type { GameState, Nation, NationStats, RelationStatus, RegionStats } from '../../shared/types'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +/** Build a NationStats from the four canonical indices (military, economy, stability, population). */ +function NS(military: number, economy: number, stability: number, population: number): NationStats { + return { military, economy, stability, population }; +} + +/** Build a RegionStats from the four canonical indices. */ +function RS(military: number, economy: number, stability: number, population: number): RegionStats { + return { military, economy, stability, population }; +} + +/** A minimal nation: the given id/name/stats/region-key territory, given relations. */ +function mkNation( + id: string, + name: string, + stats: NationStats, + territory: string[], + relations: Record = {}, +): Nation { + return { id, name, type: 'major', stats, territory, relations }; +} + +/** + * A minimal GameState carrying the given nations (keyed by id) and per-region + * stats. Every other required field is at its fresh-game baseline, so a test + * can focus purely on the migration effect. + */ +function mkState( + nations: Record, + regionStats: Record, +): GameState { + return { + id: 'g1', + presetId: 'test', + presetTitle: 'Test', + playerNationId: Object.keys(nations)[0] ?? 'uk', + turn: 1, + date: '1939-09-01', + nations, + events: [], + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + regionStats, + wars: [], + trades: [], + embargoes: [], + industry: [], + }; +} + +// --------------------------------------------------------------------------- +// Kind / scale constants + predicates +// --------------------------------------------------------------------------- + +describe('MIGRATION_KINDS / MIGRATION_SCALES', () => { + it('exposes exactly the four kinds and three scales', () => { + expect([...MIGRATION_KINDS]).toEqual(['migration', 'refugees', 'diaspora', 'exodus']); + expect([...MIGRATION_SCALES]).toEqual(['small', 'moderate', 'large']); + }); +}); + +describe('isMigrationKind / isMigrationScale', () => { + it('accepts each known kind and rejects unknown / non-strings', () => { + for (const k of MIGRATION_KINDS) expect(isMigrationKind(k)).toBe(true); + expect(isMigrationKind('immigration')).toBe(false); + expect(isMigrationKind(42)).toBe(false); + expect(isMigrationKind(undefined)).toBe(false); + }); + + it('accepts each known scale and rejects unknown / non-strings', () => { + for (const s of MIGRATION_SCALES) expect(isMigrationScale(s)).toBe(true); + expect(isMigrationScale('massive')).toBe(false); + expect(isMigrationScale(42)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Effect tables +// --------------------------------------------------------------------------- + +describe('MIGRATION_SOURCE_EFFECTS / MIGRATION_DESTINATION_EFFECTS', () => { + it('every source scale dips exactly population and economy (both negative)', () => { + for (const s of MIGRATION_SCALES) { + const dips = MIGRATION_SOURCE_EFFECTS[s]; + expect(Object.keys(dips).sort()).toEqual(['economy', 'population']); + expect(dips.population).toBeLessThan(0); + expect(dips.economy).toBeLessThan(0); + } + }); + + it('every destination scale gains exactly population and economy (both positive)', () => { + for (const s of MIGRATION_SCALES) { + const gains = MIGRATION_DESTINATION_EFFECTS[s]; + expect(Object.keys(gains).sort()).toEqual(['economy', 'population']); + expect(gains.population).toBeGreaterThan(0); + expect(gains.economy).toBeGreaterThan(0); + } + }); + + it('source loses and destination gains monotonically with scale', () => { + const [small, moderate, large] = MIGRATION_SCALES; + const srcPop = (s: MigrationScale) => MIGRATION_SOURCE_EFFECTS[s].population!; + const dstPop = (s: MigrationScale) => MIGRATION_DESTINATION_EFFECTS[s].population!; + // A larger scale means the source LOSES more: the (negative) dip grows more + // negative as the scale grows, i.e. small > moderate > large. + expect(srcPop(small)).toBeGreaterThan(srcPop(moderate)); + expect(srcPop(moderate)).toBeGreaterThan(srcPop(large)); + // A larger scale means the destination GAINS more: the (positive) gain + // grows larger as the scale grows, i.e. small < moderate < large. + expect(dstPop(small)).toBeLessThan(dstPop(moderate)); + expect(dstPop(moderate)).toBeLessThan(dstPop(large)); + }); + + it('the large-influx stability strain is a small negative dip', () => { + expect(LARGE_INFLUX_STABILITY_DIP).toBeLessThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// validateMigrationOps +// --------------------------------------------------------------------------- + +describe('validateMigrationOps', () => { + it('accepts the { ops: [...] } object shape and a bare array', () => { + const op = { kind: 'migration', from: 'a', to: 'b', scale: 'small' }; + expect(validateMigrationOps({ ops: [op] })).toEqual([op]); + expect(validateMigrationOps([op])).toEqual([op]); + }); + + it('drops an op with an unknown / missing kind', () => { + expect(validateMigrationOps([{ kind: 'tsunami', from: 'a', to: 'b', scale: 'small' }])).toEqual( + [], + ); + expect( + validateMigrationOps([ + { from: 'a', to: 'b', scale: 'small' }, + { kind: 'migration', from: 'a', to: 'b', scale: 'small' }, + ]), + ).toEqual([{ kind: 'migration', from: 'a', to: 'b', scale: 'small' }]); + }); + + it('drops an op with an unknown / missing scale', () => { + expect(validateMigrationOps([{ kind: 'migration', from: 'a', to: 'b', scale: 'massive' }])).toEqual( + [], + ); + expect(validateMigrationOps([{ kind: 'migration', from: 'a', to: 'b' }])).toEqual([]); + }); + + it('drops an op with an empty / missing from (the source)', () => { + expect(validateMigrationOps([{ kind: 'migration', from: '', to: 'b', scale: 'small' }])).toEqual( + [], + ); + expect(validateMigrationOps([{ kind: 'migration', to: 'b', scale: 'small' }])).toEqual([]); + }); + + it('requires a non-empty `to` for every kind except exodus', () => { + // non-exodus kind missing `to`: dropped + expect(validateMigrationOps([{ kind: 'migration', from: 'a', scale: 'small' }])).toEqual([]); + expect(validateMigrationOps([{ kind: 'refugees', from: 'a', scale: 'small' }])).toEqual([]); + // exodus without a `to`: kept (an exodus has no destination) + expect(validateMigrationOps([{ kind: 'exodus', from: 'a', scale: 'small' }])).toEqual([ + { kind: 'exodus', from: 'a', scale: 'small' }, + ]); + }); + + it('caps `from` / `to` at 200 chars and `detail` at 300, dropping empty detail', () => { + const longFrom = 'f'.repeat(500); + const longDetail = 'd'.repeat(500); + const out = validateMigrationOps([ + { kind: 'migration', from: longFrom, to: 'b', scale: 'small', detail: longDetail }, + ]); + expect(out).toHaveLength(1); + expect(out[0].from).toHaveLength(200); + expect(out[0].detail).toHaveLength(300); + }); + + it('a missing / malformed top-level value yields an empty list', () => { + expect(validateMigrationOps(undefined)).toEqual([]); + expect(validateMigrationOps(null)).toEqual([]); + expect(validateMigrationOps('nope')).toEqual([]); + expect(validateMigrationOps({ ops: 'nope' })).toEqual([]); + expect(validateMigrationOps([{ kind: 'migration' }])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// shiftRelationTowardPositive +// --------------------------------------------------------------------------- + +describe('shiftRelationTowardPositive', () => { + function pair(rel: Record): GameState { + return mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1'], rel), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1'], rel), + }, + { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, + ); + } + + it('nudges a neutral pair one step toward positive, symmetrically', () => { + const s = pair({ b2: 'neutral', a1: 'neutral' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('ally'); + expect(s.nations.b2.relations.a1).toBe('ally'); + }); + + it('never moves past the cap (migration/diaspora cap at neutral)', () => { + // hostile → rival (one step toward positive, still below the neutral cap) + let s = pair({ b2: 'hostile', a1: 'hostile' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); + expect(s.nations.a1.relations.b2).toBe('rival'); + // rival → neutral (reaches the cap) + s = pair({ b2: 'rival', a1: 'rival' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); + expect(s.nations.a1.relations.b2).toBe('neutral'); + // neutral → stays neutral (at the cap) + s = pair({ b2: 'neutral', a1: 'neutral' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); + expect(s.nations.a1.relations.b2).toBe('neutral'); + // ally → stays ally (more positive than the cap) + s = pair({ b2: 'ally', a1: 'ally' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'neutral'); + expect(s.nations.a1.relations.b2).toBe('ally'); + }); + + it('refugees can nudge all the way to ally (cap ally)', () => { + let s = pair({ b2: 'hostile', a1: 'hostile' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('rival'); + s = pair({ b2: 'rival', a1: 'rival' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('neutral'); + s = pair({ b2: 'neutral', a1: 'neutral' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('ally'); + // already ally: no change + s = pair({ b2: 'ally', a1: 'ally' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('ally'); + }); + + it('a missing relation starts at neutral and nudges toward the cap', () => { + const s = pair({}); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('ally'); + expect(s.nations.b2.relations.a1).toBe('ally'); + }); + + it('never touches a war relation', () => { + const s = pair({ b2: 'war', a1: 'war' }); + shiftRelationTowardPositive(s, 'a1', 'b2', 'ally'); + expect(s.nations.a1.relations.b2).toBe('war'); + expect(s.nations.b2.relations.a1).toBe('war'); + }); + + it('is a no-op for an unknown nation id or an unknown cap', () => { + const s = pair({ b2: 'hostile', a1: 'hostile' }); + shiftRelationTowardPositive(s, 'a1', 'zzz', 'ally'); // zzz absent + expect(s.nations.a1.relations.b2).toBe('hostile'); + shiftRelationTowardPositive(s, 'a1', 'b2', 'nope' as RelationStatus); // bad cap + expect(s.nations.a1.relations.b2).toBe('hostile'); + }); +}); + +// --------------------------------------------------------------------------- +// applyMigrationOp +// --------------------------------------------------------------------------- + +describe('applyMigrationOp', () => { + it('a small migration: source loses pop+eco, destination gains pop+eco, relation unchanged (neutral→neutral)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }); + // source: economy 70→69, population 50→49 (re-derived from its single region) + expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); + // destination: economy 30→31, population 60→61 + expect(state.nations.b2.stats).toEqual(NS(40, 31, 50, 61)); + // both regions mirrored + expect(state.regionStats['a-r1']).toEqual(RS(80, 69, 60, 49)); + expect(state.regionStats['b-r1']).toEqual(RS(40, 31, 50, 61)); + // a neutral pair is already at the neutral cap: no relation change + expect(state.nations.a1.relations.b2 ?? 'neutral').toBe('neutral'); + expect(state.nations.b2.relations.a1 ?? 'neutral').toBe('neutral'); + }); + + it('resolves `from` / `to` by name (not just id)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'migration', from: 'Alpha', to: 'Bravo', scale: 'moderate' }); + // moderate: source pop -2 / eco -2; destination pop +2 / eco +2 + expect(state.nations.a1.stats).toEqual(NS(80, 68, 60, 48)); + expect(state.nations.b2.stats).toEqual(NS(40, 32, 50, 62)); + }); + + it('a large influx strains the destination stability by 2', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); + // destination: eco 30→33, pop 60→63, stability 50→48 (large influx strain) + expect(state.nations.b2.stats).toEqual(NS(40, 33, 48, 63)); + expect(state.regionStats['b-r1']).toEqual(RS(40, 33, 48, 63)); + // source: eco 70→67, pop 50→46 + expect(state.nations.a1.stats).toEqual(NS(80, 67, 60, 46)); + }); + + it('a refugees flow nudges the relation toward ally (cap ally); migration/diaspora stop at neutral', () => { + const mk = (rel: RelationStatus) => + mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: rel }), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: rel }), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + // refugees: hostile → rival + let s = mk('hostile'); + applyMigrationOp(s, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'small' }); + expect(s.nations.a1.relations.b2).toBe('rival'); + // migration: hostile → rival (capped at neutral, one step only) + s = mk('hostile'); + applyMigrationOp(s, { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }); + expect(s.nations.a1.relations.b2).toBe('rival'); + // migration: rival → neutral (reaches cap) + s = mk('rival'); + applyMigrationOp(s, { kind: 'diaspora', from: 'a1', to: 'b2', scale: 'small' }); + expect(s.nations.a1.relations.b2).toBe('neutral'); + // refugees: neutral → ally + s = mk('neutral'); + applyMigrationOp(s, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'small' }); + expect(s.nations.a1.relations.b2).toBe('ally'); + }); + + it('never nudges a warring pair (a war is left untouched)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: 'war' }), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: 'war' }), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); + expect(state.nations.a1.relations.b2).toBe('war'); + expect(state.nations.b2.relations.a1).toBe('war'); + // the stat effects still apply (a war does not block population movement) + expect(state.nations.a1.stats).toEqual(NS(80, 67, 60, 46)); + expect(state.nations.b2.stats).toEqual(NS(40, 33, 48, 63)); + }); + + it('an exodus only affects the source: no destination, no relation nudge', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1'], { b2: 'rival' }), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1'], { a1: 'rival' }), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'exodus', from: 'a1', scale: 'small' }); + // source dips + expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); + // destination untouched + expect(state.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); + expect(state.regionStats['b-r1']).toEqual(RS(40, 30, 50, 60)); + // no relation nudge + expect(state.nations.a1.relations.b2).toBe('rival'); + }); + + it('a self-flow (from === to) is dropped entirely', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) }, + { 'a-r1': RS(80, 70, 60, 50) }, + ); + applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'a1', scale: 'large' }); + // no change at all + expect(state.nations.a1.stats).toEqual(NS(80, 70, 60, 50)); + expect(state.regionStats['a-r1']).toEqual(RS(80, 70, 60, 50)); + }); + + it('an unknown or ambiguous source / destination drops the op', () => { + const base = () => + mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + // unknown source + let s = base(); + applyMigrationOp(s, { kind: 'migration', from: 'zzz', to: 'b2', scale: 'small' }); + expect(s.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); + // unknown destination + s = base(); + applyMigrationOp(s, { kind: 'migration', from: 'a1', to: 'zzz', scale: 'small' }); + expect(s.nations.a1.stats).toEqual(NS(80, 70, 60, 50)); + // ambiguous source (two nations share the name) + const amb = mkState( + { + a1: mkNation('a1', 'Twin', NS(80, 70, 60, 50), ['a-r1']), + a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), ['a-r2']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'a-r2': RS(20, 20, 20, 20), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(amb, { kind: 'migration', from: 'Twin', to: 'Bravo', scale: 'small' }); + expect(amb.nations.b2.stats).toEqual(NS(40, 30, 50, 60)); + }); + + it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => { + const state = mkState( + { + // a1 owns no regions — its stats are maintained directly, not derived + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), []), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'moderate' }); + // source (0 regions) keeps the direct dip: eco 70→68, pop 50→48 + expect(state.nations.a1.stats).toEqual(NS(80, 68, 60, 48)); + // destination re-derived from its region + expect(state.nations.b2.stats).toEqual(NS(40, 32, 50, 62)); + }); + + it('never moves territory (sole ownership untouched)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + applyMigrationOp(state, { kind: 'refugees', from: 'a1', to: 'b2', scale: 'large' }); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.nations.b2.territory).toEqual(['b-r1']); + }); + + it('clamps affected stats to 0 and 100 (a source at the floor, a destination at the ceiling)', () => { + const state = mkState( + { + // a1 at the population / economy floor + a1: mkNation('a1', 'Alpha', NS(5, 3, 50, 1), ['a-r1']), + // b2 near the ceiling: a large gain (+3) pushes it past 100 + b2: mkNation('b2', 'Bravo', NS(90, 98, 50, 98), ['b-r1']), + }, + { 'a-r1': RS(5, 3, 50, 1), 'b-r1': RS(90, 98, 50, 98) }, + ); + applyMigrationOp(state, { kind: 'migration', from: 'a1', to: 'b2', scale: 'large' }); + // source clamps at 0 (pop 1-4→0, eco 3-3→0) + expect(state.nations.a1.stats.population).toBe(0); + expect(state.nations.a1.stats.economy).toBe(0); + // destination gains but clamps at 100 (pop 98+3→100, eco 98+3→100) + expect(state.nations.b2.stats.population).toBe(100); + expect(state.nations.b2.stats.economy).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object contract +// --------------------------------------------------------------------------- + +describe('migration ability', () => { + it('owns the "migration" field; default() is a fresh empty { ops: [] }', () => { + expect(migration.field).toBe('migration'); + expect(migration.default()).toEqual({ ops: [] }); + expect(migration.default()).not.toBe(migration.default()); + }); + + it('default() is not meaningful; a populated value is', () => { + expect(migration.isMeaningful(migration.default())).toBe(false); + expect( + migration.isMeaningful({ ops: [{ kind: 'exodus', from: 'a1', scale: 'small' }] }), + ).toBe(true); + }); + + it('validate() wraps the bare-list validator into the { ops } shape', () => { + expect( + migration.validate([{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }]), + ).toEqual({ ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] }); + expect(migration.validate(undefined)).toEqual({ ops: [] }); + }); + + it('the stub value equals default() (the stub never proposes a migration)', () => { + expect(migration.stub()).toEqual({ ops: [] }); + }); + + it('payload() is always empty (turn-transient — no durable state to show)', () => { + expect(migration.payload({} as never)).toEqual({}); + }); + + it('stateFields is undefined (the layer owns no durable GameState field)', () => { + expect(migration.stateFields).toBeUndefined(); + }); + + it('apply() runs each validated op against the world (no throw on a bad ref)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(40, 30, 50, 60), ['b-r1']), + }, + { 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(40, 30, 50, 60) }, + ); + const ctx = { previous: state, actingTurn: state.turn }; + migration.apply( + state, + { + ops: [ + { kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }, + // an unknown source — must be dropped, not throw + { kind: 'refugees', from: 'ghost', to: 'b2', scale: 'small' }, + ], + }, + ctx, + ); + expect(state.nations.a1.stats).toEqual(NS(80, 69, 60, 49)); + expect(state.nations.b2.stats).toEqual(NS(40, 31, 50, 61)); + }); +}); diff --git a/server/abilities/migration.ts b/server/abilities/migration.ts new file mode 100644 index 0000000..c139c78 --- /dev/null +++ b/server/abilities/migration.ts @@ -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(MIGRATION_KINDS); +const VALID_SCALES = new Set(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>> = { + 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>> = + { + 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).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; + 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>, + 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> = { ...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":"","from":"","to":"","scale":"","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: () => ({}), +}; diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts index 4ed62df..228603b 100644 --- a/server/abilities/registry.test.ts +++ b/server/abilities/registry.test.ts @@ -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(); diff --git a/server/abilities/types.ts b/server/abilities/types.ts index 1cb4181..7e82575 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -32,7 +32,8 @@ export type AbilityField = | 'nuclear' | 'leaders' | 'ideology' - | 'disasters'; + | 'disasters' + | 'migration'; /** * Everything a single ability's `apply` may need from the surrounding turn. diff --git a/server/engine.test.ts b/server/engine.test.ts index 04721ec..ac11fad 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -570,7 +570,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -592,7 +592,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -613,7 +613,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -632,7 +632,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1089,7 +1089,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1106,7 +1106,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1129,7 +1129,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1155,7 +1155,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1173,7 +1173,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1263,7 +1263,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1378,7 +1378,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1685,9 +1685,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1700,7 +1700,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1721,7 +1721,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); @@ -1742,7 +1742,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1765,7 +1765,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1798,7 +1798,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.playerNationId).toBe('uk'); // player still leads it @@ -1822,7 +1822,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no nation was renamed @@ -1849,7 +1849,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // neither was renamed (ambiguous) @@ -1875,7 +1875,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); const next = applyTurnResult(state, validated!); @@ -1901,7 +1901,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.uk.name).toBe('The Commonwealth'); @@ -1925,7 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.version).toBe(2); // exactly one bump for the whole turn @@ -1947,7 +1947,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1981,7 +1981,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); @@ -2005,13 +2005,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -2038,7 +2038,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped @@ -2085,7 +2085,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, @@ -2110,7 +2110,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2126,14 +2126,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2203,7 +2203,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), @@ -2300,6 +2300,7 @@ function mkTurnResult(partial: Partial): TurnResult { leaders: partial.leaders ?? { changes: [] }, ideology: partial.ideology ?? { changes: [] }, disasters: partial.disasters ?? { events: [] }, + migration: partial.migration ?? { ops: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3025,7 +3026,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); // the valid one survives, the empty-name one is dropped @@ -3413,7 +3414,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3434,7 +3435,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3455,7 +3456,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] },markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); }); diff --git a/server/engine.ts b/server/engine.ts index 88c7039..c1be510 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -94,6 +94,16 @@ export { DISASTER_TYPES, DISASTER_SEVERITIES, DISASTER_EFFECTS, + validateMigrationOps, + applyMigrationOp, + shiftRelationTowardPositive, + isMigrationKind, + isMigrationScale, + MIGRATION_KINDS, + MIGRATION_SCALES, + MIGRATION_SOURCE_EFFECTS, + MIGRATION_DESTINATION_EFFECTS, + LARGE_INFLUX_STABILITY_DIP, } from './abilities'; // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- @@ -622,6 +632,7 @@ export function validateTurnResult( leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, + migration: { ops: [] }, markers: { add: [], remove: [] }, }; diff --git a/shared/protocol.ts b/shared/protocol.ts index a0ffc88..1553110 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -650,6 +650,63 @@ export interface DisastersOps { events: DisasterEvent[]; } +// --- Migration & refugees (v0.42.0, #64) -------------------------------------- +// +// 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). Each op names 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 +// `MIGRATION_SOURCE_EFFECTS` / `MIGRATION_DESTINATION_EFFECTS` (defined in +// `server/abilities/migration.ts`) — 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 the hybrid / economy / +// disasters layers). The from↔to relation moves one step toward the positive +// end of the ally/neutral/rival/hostile ladder (`shiftRelationTowardPositive`) +// — capped at `ally` for `refugees`, `neutral` for `migration` / `diaspora`, +// and not at all for `exodus` — and a `war` relation is never touched. It is +// **turn-transient** like the hybrid / disasters layers: there is NO `GameState` +// field, no durable list, and no `db.ts` change — a continuing flow is simply +// re-declared each turn it persists. It never moves territory (sole ownership +// is untouched) and never creates or escalates a war. + +/** The kind of population flow a migration op describes (v0.42.0, #64). */ +export type MigrationKind = 'migration' | 'refugees' | 'diaspora' | 'exodus'; + +/** The size of a migration flow (v0.42.0, #64). */ +export type MigrationScale = 'small' | 'moderate' | 'large'; + +/** + * A single migration / refugee flow (v0.42.0, #64): the `kind`, the source + * `from` (a nation id / name — the nation people leave), the destination `to` + * (a nation id / name — required for every kind except `exodus`, which has no + * destination), the `scale`, and an optional short `detail`. The deterministic + * stat effects are looked up from `MIGRATION_SOURCE_EFFECTS` / + * `MIGRATION_DESTINATION_EFFECTS` by `scale`. + */ +export interface MigrationOp { + /** The kind of flow (one of {@link MigrationKind}). */ + kind: MigrationKind; + /** The source nation (by id or name) people leave. */ + from: string; + /** The destination nation (by id or name). Required for every kind except `exodus`. */ + to?: string; + /** How many people moved (one of {@link MigrationScale}). */ + scale: MigrationScale; + /** Optional short annotation (≤300 chars). */ + detail?: string; +} + +/** The migration block of a `TurnResult`. Required; default `{ ops: [] }`. */ +export interface MigrationOps { + ops: MigrationOp[]; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -819,6 +876,29 @@ export interface TurnResult { * `{ events: [] }`. */ disasters: DisastersOps; + /** + * (v0.42.0, #64) Migration & refugee flows this turn — people moving between + * nations: ordinary `migration`, `refugees` fleeing a war or disaster, a + * spreading `diaspora`, and an `exodus` (people leaving with no destination). + * Each op names a `from` (the source nation, by id or name) and, for every + * kind except `exodus`, a `to` (the destination nation, by id or name), plus + * a `scale` (small / moderate / large). Applied directly by `applyTurnResult` + * (like the disasters / hybrid layers): 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), both clamped to 0–100, mirrored to each side's regions, + * and each side's derived `stats` re-derived afterwards; the from↔to + * relation moves one step toward the positive end of the + * ally/neutral/rival/hostile ladder (capped at `ally` for `refugees`, + * `neutral` for `migration` / `diaspora`, not at all for `exodus`; a `war` + * relation is never touched). The layer is turn-transient (no `GameState` + * field, no `db.ts` change) — a continuing flow is simply re-declared each + * turn it persists. It never moves borders (sole ownership is untouched) and + * never creates or escalates a war. At most 1–2 per turn, each narrated in + * `events` (a `social` or `diplomatic` event). Required; default + * `{ ops: [] }`. + */ + migration: MigrationOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small