Compare commits

..
Author SHA1 Message Date
kamilkrzywda c77e81c45b feat: #65 sea control — 11th ability (v0.43.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Adds the sea ability: a fixed set of 12 named sea zones with sole-control
semantics, contest-by-higher-military, and a deterministic per-turn
+1 military/+1 economy bonus for each claimed zone. Durable (owns
GameState.seaZones), additive migration, stub-inert (e2e unaffected).

Fixes #65

Design note: Gitea #65 sketched a free-form 'naval' layer; the implemented
approved design is this richer 'sea' layer with a fixed zone set.
2026-10-16 12:50:22 +00:00
kamilkrzywda 13f91845fd Merge pull request 'feat(abilities): migration & refugees lever (v0.42.0, #64)' (#77) from feat/64-migration into master
CI / build + unit + e2e (push) Successful in 2m33s
2026-10-16 12:20:03 +02:00
kamilkrzywda 7e4faa73d3 feat(abilities): add migration & refugees lever (v0.42.0, #64)
CI / build + unit + e2e (pull_request) Successful in 2m12s
Add the 10th modular LLM ability: a turn-transient TurnResult.migration
lever declaring population flows between nations (migration / refugees /
diaspora / exodus). The source loses a little population + economy, the
destination gains a little (a large influx also strains its stability),
and the from<->to relation is nudged one step toward the positive end of
the ally/neutral/rival/hostile ladder (capped by kind; a war is never
touched). Deterministic per-scale tables, no hidden randomness, clamped
0-100, mirrored to owned regions, re-derived aggregates; never moves
territory. Registered last in ABILITIES; user prompt unchanged
(user.txt golden byte-identical), system goldens re-baselined. Fixes #64.
2026-10-16 10:15:45 +00:00
kamilkrzywda e0f3d0313b Merge pull request 'feat(abilities): disasters & pandemics lever (v0.41.0)' (#76) from feat/63-disasters-pandemics into master
CI / build + unit + e2e (push) Successful in 2m1s
2026-10-16 11:20:16 +02:00
kamilkrzywda c90e9ae753 feat(abilities): add disasters & pandemics lever (v0.41.0, #63)
CI / build + unit + e2e (pull_request) Successful in 1m55s
Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.

- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
  DisastersOps, and `TurnResult.disasters` (between `ideology` and
  `markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
  schemaLine / constraint / isMeaningful / inert stub / empty payload; no
  stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
  dip table (population never touched). Target resolution mirrors hybrid
  (a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
  `from` field, so there is no self-target drop. Dips clamp to 0-100,
  mirror to the target's regions (regionStats), and re-derive the target
  nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
  regenerated (one schema line + one constraint bullet each); the
  user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
  updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
  the Layout tree + a "how to add a new ability" bullet + a disasters
  bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
  `as unknown` casts into the top-level ./abilities import, and de-dup
  `str` (now imported from ./abilities/shared).

Version bumped 0.40.0 -> 0.41.0.
2026-10-16 09:15:55 +00:00
26 changed files with 3076 additions and 119 deletions
+141
View File
@@ -96,6 +96,16 @@ history/
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
│ ├── presets.ts # load preset JSON files from disk
│ ├── db.ts # SQLite open/save/load (+ legacy regionStats migration on load, v0.28 #46)
│ ├── abilities/ # the modular LLM-ability system (v0.40.0 #67) — one file per world lever
│ │ ├── index.ts # the ABILITIES registry (list order = apply order) + the re-export surface
│ │ ├── types.ts # the Ability contract + the AbilityField union
│ │ ├── shared.ts # shared helpers (stat dips / re-derive / ref resolution, …)
│ │ ├── apply-helpers.ts # shared apply-path helpers
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
│ │ │ # 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/
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
@@ -442,6 +452,137 @@ history/
(`industry: { projects: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested
only (`server/engine.test.ts`, `server/protocol.test.ts`, `server/regions.test.ts`,
`server/db.test.ts`, `scripts/build-region-resources.test.ts`).
- **Modular LLM abilities — how to add a new one (v0.40.0, #67).**
Since v0.40.0 the per-feature LLM levers are a modular registry in `server/abilities/`: one
file per ability plus `types.ts` (the `Ability<K>` interface, the closed `AbilityField` union,
and the `AbilityCtx` / `UserPromptCtx` shapes), `shared.ts` (the dependency-free `str` /
`resolveNationRef` / `resolveRegionKey` / fresh-id primitives the abilities share), and
`index.ts` (the ordered `ABILITIES` array — its position is the canonical apply / prompt /
payload order). Each ability is a plain object implementing `Ability<K>`: `field` (its
`TurnResult` field name), `default()` (a FRESH no-op value), `validate(raw)` (defensive parse
into the field's exact shape — never throws), `apply(state, data, ctx)` (mutate the world in
place — never throws), `schemaLine()` (its system-prompt JSON line), `constraint()` (its HARD
CONSTRAINTS bullet), `isMeaningful(data)` (does a non-default value make the turn meaningful?),
`stateFields?` (the `GameState` fields it owns, for the `db.ts` legacy-row migration —
`undefined` for turn-transient abilities), `stub()` (the deterministic stub's value — every
ability returns `default()`, so e2e is never affected), and `payload(ctx)` (the user-payload
keys it contributes — `{}` for turn-transient ones). The engine consumes the registry
generically and is never edited to add an ability: `buildSystemPrompt` emits each
`schemaLine()` / `constraint()` in `ABILITIES` order (between the `nationOps` block/line and the
`markers` line / "- Prefer 1-4 events" bullet), `buildUserPrompt` merges each `payload()` at its
historical position, `validateTurnResult` fills each field via `a.validate()`, `isMeaningful`
ORs the per-ability predicates, `applyTurnResult` runs `a.apply` in `ABILITIES` order (after the
core effects, before markers), the stub fills each field via `a.stub()`, and the "unclear"
fallback uses `a.default()`. To add a new ability: (1) add the field's type to `TurnResult` in
`shared/protocol.ts` (a required field with a default) and the field name to the `AbilityField`
union in `types.ts`; (2) create `server/abilities/<name>.ts` exporting an `Ability<'<name'>`
const; (3) import it in `index.ts` and APPEND it to `ABILITIES` (append last — never reorder
existing entries; their order is pinned by the goldens and the stub's byte-identical output);
(4) re-export any new public names from `index.ts` and from `engine.ts`; (5) if it owns a durable
`GameState` field, add it to `shared/types.ts` + the `db.ts` legacy-row migration (additive,
never wiped) and declare it in `stateFields`; if it contributes to the user payload, return its
keys from `payload()` and extend `UserPromptCtx`; (6) write `server/abilities/<name>.test.ts`
(the stub never exercises a new ability, so its apply path is unit-tested only) and — if it
changes the system prompt — regenerate the goldens
(`REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts`); `user.txt` must stay
byte-identical unless the new ability contributes user-payload keys.
- **Disasters & pandemics — natural / man-made catastrophes with a deterministic dip (v0.41.0, #63).**
`TurnResult.disasters = DisastersOps = { events: DisasterEvent[] }` (required, default
`{ events: [] }`); `DisasterEvent = { type, target, severity, detail? }` with `type ∈
DISASTER_TYPES = earthquake | flood | volcanic | wildfire | famine | plague | epidemic` and
`severity ∈ DISASTER_SEVERITIES = minor | moderate | severe`. Like the hybrid layer this is
**turn-transient**: it owns NO `GameState` field (no durable list, no `db.ts` change) — a
plague or epidemic that persists is simply re-declared each turn it continues. The DETERMINISTIC
`DISASTER_EFFECTS` table (`server/abilities/disasters.ts`) maps each `(type, severity)` to a
small set of signed stat deltas (a severe plague is the hardest hit; none of them touch
`population`, and none move borders — sole ownership is untouched). The `target` resolves like a
hybrid op — a nation ref via the shared `resolveNationRef` (id first, then a case-insensitive
unique name), else a region key that hits that region's SOLE owner (a key with 0 or 2+ owners, or
one absent from the valid-keys manifest, is dropped); there is no `from`/actor field — a
catastrophe is not an attack — so (unlike hybrid) a self-target drop does NOT apply.
`applyDisasterEvent` (run in the `ABILITIES` loop after territory / wars / economy, before
markers) dips the target nation's `stats` (clamped 0–100), mirrors the dip to its regions (the
single named region for a region target, else every region it owns — entries with no
`regionStats` are skipped), and re-derives the nation's aggregate stats (the same 0-region /
no-entry guards, so a 0-region nation keeps its directly-mutated value and the dip 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.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).
- **Sea control — sole control of a fixed set of 12 named sea zones with a deterministic per-turn bonus (v0.43.0, #65).**
`TurnResult.sea = SeaOps = { control: SeaControlOp[] }` (required, default `{ control: [] }`);
`SeaControlOp = { zone, by }` where `zone` is one of the 12 fixed sea-zone ids from
`SEA_ZONES` in `shared/protocol.ts` (`north-atlantic`, `south-atlantic`, `pacific`, `indian`,
`mediterranean`, `north-sea`, `baltic`, `arctic`, `black-sea`, `gulf-mexico`, `red-sea`,
`sargasso`) and `by` is a nation ref (id first, then a case-insensitive unique name, via the
shared `resolveNationRef`; unknown / ambiguous → dropped, never thrown). Like the wars and
industry layers this is **durable** (unlike the turn-transient hybrid / disasters / migration
layers): it owns the `GameState.seaZones` field (required, `[]` at creation; a legacy SQLite
row without the field loads as `[]` — via the generic `ABILITIES` `stateFields` loop in
`db.ts`, no `db.ts` code change needed). `applySeaControl` (run in the `ABILITIES` loop after
territory / wars / economy, before markers) upserts the turn's ops into `next.seaZones`:
per zone (in first-appearance order) a single surviving op sets or replaces the zone's
controller (replace in place — preserving position — or append); two or more surviving ops
on the same zone for different resolved nations make the zone **contested** — the higher
current `stats.military` wins (a tie goes to the earlier-listed op) — deterministic, no
hidden randomness. The layer **never touches `territory` / `unowned`** — it is fully parallel
to but independent of land. Every committed turn (including a turn with an empty `control`),
`applySeaZoneBonuses` grants each existing claim's controller `SEA_ZONE_BONUS = 1` military
+ 1 economy (clamped to 0–100, mirrored to every region the nation owns, then the nation's
derived `stats` re-derived with the same 0-region / no-entry guards as the economy / migration
layers, so a 0-region nation keeps its directly-mutated value); a nation controlling several
zones stacks the bonus; a claim whose nation no longer exists (removed by a merge / split
this turn) is skipped — the claim is kept, never thrown. `isMeaningful` is
`control.length > 0`. The system prompt documents the field + constraints (at most 1–2 claims
per turn, each narrated in `events`); the user payload **always** carries the current
`seaZones` list (even when empty) — `payload()` → `{ seaZones: ctx.seaZones ?? [] }` — the
one ability whose `payload()` is not `{}` (the durable list, unlike the turn-transient
layers). The **stub never** proposes a sea op (`{ control: [] }`), so e2e (CI, `LLM_STUB=1`)
is unaffected and the path is unit-tested only (`server/abilities/sea.test.ts`).
**Design note:** the Gitea #65 body sketched a `naval` layer with a free-form `zones`
array; the implemented, approved design is this richer `sea` layer with a **fixed set of
12 named zones**, sole-control semantics, contest-by-higher-military, and a durable
`seaZones` list — a cleaner, more deterministic model that the LLM can reason about without
inventing zone names each turn.
- **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
+40
View File
@@ -2,6 +2,46 @@
All notable changes to **History** are listed here, newest first.
## v0.43.0 — 2026-10-16 12:09
- **#65: Sea control — a `TurnResult.sea` lever letting the LLM declare sole control over a fixed set of 12 named sea zones (North Atlantic, South Atlantic, Pacific, Indian, Mediterranean, North Sea, Baltic, Arctic, Black Sea, Gulf of Mexico, Red Sea, Sargasso), with a deterministic per-turn +1 military / +1 economy bonus for the controlling nation.** It is a **durable** feature (unlike the turn-transient `hybrid` / `disasters` / `migration` layers): `GameState.seaZones` persists across turns and in SQLite, and the user payload **always** carries the current `seaZones` list (even when empty) so the model knows who controls what. It lands as the **11th ability** of the v0.40.0 modular system, in a new `server/abilities/sea.ts` module, **registered last** in the `ABILITIES` registry (after `migration`). Sea control is fully **parallel to but independent of land**: it never touches `territory` / `unowned`, and moving land never touches a sea zone.
- **Types (`shared/protocol.ts`).** New `SEA_ZONES` (a frozen array of 12 `{ id, name }` pairs), `SEA_ZONE_IDS` (a `ReadonlySet` of all 12 ids), `isSeaZoneId(id)` (type guard), `seaZoneName(id)` (human-readable name; unknown → raw), `SeaControlOp` (`{ zone; by }`), and `SeaOps` (`{ control: SeaControlOp[] }`); `TurnResult.sea` is **required**, placed between `migration` and `markers`, default `{ control: [] }`. New `SeaZoneClaim` (`{ zone; by }`) in `shared/types.ts`; `GameState.seaZones: SeaZoneClaim[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed).
- **Ability module (`server/abilities/sea.ts`).** `field: 'sea'`; `default()` / `stub()` → a **fresh** `{ control: [] }` each call; `validate` wraps the exported `validateSeaOps` (defensive, never throws — accepts the `{ control: [...] }` object or a bare array; each op needs a valid `zone` (one of the 12 `SEA_ZONE_IDS`) and a non-empty `by` (trimmed, ≤200) — anything else is dropped); `apply` runs `applySeaControl` then `applySeaZoneBonuses`; `schemaLine()` / `constraint()` supply the prompt text; **`payload()` → `{ seaZones: ctx.seaZones ?? [] }` — the one ability that always emits a user-payload key** (the durable list, even when empty — unlike the turn-transient layers whose `payload()` is `{}`); `isMeaningful` = `control.length > 0`; **`stateFields: ['seaZones']`** (the durable `GameState` field, for the `db.ts` legacy-row migration).
- **Contest semantics (`applySeaControl`).** Each op's `by` is resolved via the shared `resolveNationRef` (id first, then a case-insensitive unique name; unknown / ambiguous → dropped, never thrown). Per zone (in first-appearance order): a single surviving op **sets or replaces** the zone's controller in `GameState.seaZones` (replace in place — preserving the entry's position — or append a new one); two or more surviving ops on the same zone for **different** resolved nations make the zone **contested**: the higher current `stats.military` wins (a tie goes to the op listed earlier in the `control` array) — deterministic, no hidden randomness. The layer **never touches `territory` / `unowned`**.
- **Per-turn bonus (`applySeaZoneBonuses`).** Every committed turn — **including a turn with an empty `control`** — each existing claim's controller earns `SEA_ZONE_BONUS = 1` military + 1 economy (clamped to 0–100, mirrored to every region the nation owns, then the nation's derived `stats` re-derived with the same 0-region / no-entry guards as the economy / migration layers, so a 0-region nation keeps its directly-mutated value). A nation controlling several zones **stacks** the bonus (one per zone). A claim whose nation no longer exists (removed by a merge / split this turn) is **skipped** — the claim is kept, never thrown.
- **Registry & prompt.** `sea` is added **last** in `ABILITIES` (existing order untouched); the system prompt gains the `sea` schema line and its constraint bullet ("at most 1–2 claims per turn, each narrated in `events`"); the **user prompt gains `"seaZones": []` as a new last key** (the intentional deviation from the turn-transient layers — the durable list is always emitted). The two committed system-prompt **goldens** are regenerated (each gains exactly the new `sea` schema line + constraint bullet); the **`user.txt` golden gains `"seaZones": []`** as the last key (verified by diff).
- **Tests.** New `server/abilities/sea.test.ts` (**~40 tests**): the zone-id predicate sets; `validateSeaOps` (shape / leniency / drops / field caps); `applySeaControl` (set / replace-in-place / append / contest by military / tie-break / same-nation dedup / name-resolve / unresolvable drop / ambiguous drop / territory-untouched); `applySeaZoneBonuses` (+1/+1 / clamping / stacking / 0-region direct-dip / stale-controller skip / empty no-op / territory-untouched); and the ability const contract (field, `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload()` incl. `?? []`, `validate` wrapping, `apply` ctx wiring). `registry.test.ts` is updated for the **11-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable fields, composes 12 / `extended[11]`, re-export pin, user-golden 14th arg). New `db.test.ts` legacy-row test (a pre-v0.43 save without `seaZones` loads with `[]`).
- **Design note (deviation from the issue body).** The Gitea #65 body sketched a `naval` layer with a free-form `zones` array; the approved design implemented here is the richer `sea` layer with a **fixed set of 12 named zones**, sole-control semantics, contest-by-higher-military, and a durable `seaZones` list — a cleaner, more deterministic model that the LLM can reason about without inventing zone names each turn.
- **Guarantees.** No client (`src/`) changes, no new runtime deps, `shared/` keeps zero runtime deps, no `@turf`/`@types/geojson` in any `.ts`. All unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is sea-inert — it never proposes a sea op).
- **Version** bumped `0.42.0 → 0.43.0`.
## 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: [] }`.
- **Ability module (`server/abilities/disasters.ts`).** `field: 'disasters'`; `default()` / `stub()` → a **fresh** `{ events: [] }` each call; `validate` wraps the exported `validateDisasterEvents` (defensive, never throws — accepts the `{ events: [...] }` object or a bare array; each event needs a valid `type`, a valid `severity`, and a non-empty `target` (trimmed, ≤120) — anything else is dropped, with a per-turn cap); `apply` runs `applyDisasterEvent` per event; `schemaLine()` / `constraint()` supply the prompt text; `payload()` → `{}` (no user-payload keys — targets resolve from the existing ref table); `isMeaningful` = `events.length > 0`; **no** `stateFields` (turn-transient, no durable `GameState` field).
- **Deterministic effects (`DISASTER_EFFECTS`).** A pure `(type, severity) → stat-dip` table — **no hidden randomness**. `population` is never touched. Earthquake and famine dip **both** `stability` and `economy` (−2 / −4 / −7 for minor / moderate / severe); flood dips `economy` (−2 / −3 / −6) and `stability` (−1 / −2 / −4); volcanic dips `economy` (−2 / −4 / −7) and `stability` (−2 / −3 / −6); wildfire dips **only** `economy` (−2 / −4 / −7); plague dips `stability` (−3 / −5 / −9) and `military` (−2 / −3 / −6); epidemic dips `stability` (−2 / −4 / −7) and `military` (−1 / −2 / −4).
- **Apply semantics (`applyDisasterEvent`).** The `target` is resolved exactly like a hybrid op (`resolveHybridTarget`): a resolvable nation ref hits every region the nation owns; a region key hits its **sole** owner; a 0-owner or 2+-owner region key is **dropped** (never thrown). The one deliberate difference from hybrid: a disaster has **no `from` field**, so there is **no self-target drop** — a nation may suffer a disaster it itself declared. The dip is clamped to 0–100, **mirrored to every region the target owns** (`regionStats`), and the target's derived `stats` is **re-derived** afterwards (the same 0-region / no-entry guards as hybrid / economy / industry — a 0-region nation keeps its directly-mutated value). It never moves territory (sole ownership is untouched). The **stub never** declares a disaster (`{ events: [] }`), so e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
- **Registry & prompt.** `disasters` is added last in `ABILITIES`; the system prompt gains the `disasters` schema line and its constraint bullet ("at most 1–2 per turn, each narrated in `events`"); 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 `disasters` schema line + constraint bullet); the **`user.txt` golden stays byte-identical** (no new user-payload keys — verified by checksum).
- **Tests.** New `server/abilities/disasters.test.ts` (**33 tests**): the type/severity predicate sets; `validateDisasterEvents` (shape / leniency / drops / cap); `applyDisasterEvent` exact dips (nation target, single-region target, region gated by `validKeys`, clamping at 0, 0-owner / 2+-owner region drops, unknown-key drop, unknown / ambiguous ref drops, case-insensitive name resolve, 0-region direct-dip persistence, missing-`regionStats`-entry skip); 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 **9-ability** contract (order, fields, `fullDefaultTurn()`, `meaningfulFor`, re-export pin); the goldens are regenerated (verified idempotent).
- **Secondary cleanups from the v0.40.0 refactor (folded into this release):**
- **AGENTS.md** — `server/abilities/` is added to the Layout tree (the per-ability files, tests, and goldens); Conventions gains a "Modular LLM abilities — how to add a new one (v0.40.0, #67)" bullet and a "Disasters & pandemics (v0.41.0, #63)" bullet.
- The **orphaned `validateTakeovers` JSDoc** (left over after the v0.40 refactor removed the function body) is deleted.
- Both **inline `as unknown` double-casts** are folded into the single top-level `./abilities` import (`AbilityCtx` + `UserPromptCtx` types).
- **`str()` is de-duplicated** — imported from `./abilities/shared` instead of the local copy.
- **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 disasters-inert).
- **Version** bumped `0.40.0 → 0.41.0`.
## v0.40.0 — 2026-10-16 07:14
- **#67: Modular LLM-ability system — the 8 per-feature LLM abilities are extracted from `server/engine.ts` into an ordered `ABILITIES` registry (`server/abilities/`).** A pure, **behavior-preserving** refactor: each per-ability concern (validate / schema-line / constraint / payload / meaningfulness / apply / stub-default / durable-state-field) moves into one file per ability, assembled into a single ordered registry that the engine iterates uniformly. **Nothing observable changes** — the system prompt, user prompt, `applyTurnResult` world-state mutations, and the deterministic stub are all byte-for-byte identical to v0.39.0 (pinned by new golden fixtures + registry tests), and `server/engine.ts` re-exports every name its existing callers import, so `server/index.ts` and all tests work with zero edits.
- **New `server/abilities/` package.**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.40.0",
"version": "0.43.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+447
View File
@@ -0,0 +1,447 @@
// Unit tests for the DISASTERS & PANDEMICS ability (v0.41.0, #63) — the
// modular, turn-transient LLM-ability that applies a deterministic
// (type, severity) stat dip to a nation or a single region. 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 disaster, so the apply path
// is exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import type { AbilityCtx } from './types';
import {
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
applyDisasterEvent,
disasters,
isDisasterSeverity,
isDisasterType,
validateDisasterEvents,
} from './disasters';
import type { DisasterEvent, DisastersOps } from '../../shared/protocol';
import type { GameState, Nation, NationStats, 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, neutral relations. */
function mkNation(id: string, name: string, stats: NationStats, territory: string[]): 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 disaster dip.
*/
function mkState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
): 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: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
/** A minimal AbilityCtx for the ability's `apply` (no manifests loaded). */
function mkCtx(state: GameState, validRegionKeys?: ReadonlySet<string>): AbilityCtx {
return {
previous: state,
actingTurn: state.turn,
...(validRegionKeys ? { validRegionKeys } : {}),
};
}
// ---------------------------------------------------------------------------
// Type / severity constants + predicates
// ---------------------------------------------------------------------------
describe('DISASTER_TYPES / DISASTER_SEVERITIES', () => {
it('exposes exactly the seven types and three severities', () => {
expect([...DISASTER_TYPES]).toEqual([
'earthquake',
'flood',
'volcanic',
'wildfire',
'famine',
'plague',
'epidemic',
]);
expect([...DISASTER_SEVERITIES]).toEqual(['minor', 'moderate', 'severe']);
});
it('every (type, severity) pair dips at least one stat, never population', () => {
for (const t of DISASTER_TYPES) {
for (const s of DISASTER_SEVERITIES) {
const dips = DISASTER_EFFECTS[t][s];
expect(dips).toBeDefined();
const keys = Object.keys(dips);
expect(keys.length).toBeGreaterThan(0);
expect(keys).not.toContain('population');
for (const k of keys) expect(dips[k as keyof typeof dips]).toBeLessThan(0);
}
}
});
});
describe('isDisasterType / isDisasterSeverity', () => {
it('accepts each known type and rejects unknown / non-strings', () => {
for (const t of DISASTER_TYPES) expect(isDisasterType(t)).toBe(true);
expect(isDisasterType('tsunami')).toBe(false);
expect(isDisasterType('Earthquake')).toBe(false); // case-sensitive
expect(isDisasterType('')).toBe(false);
expect(isDisasterType(42)).toBe(false);
expect(isDisasterType(null)).toBe(false);
expect(isDisasterType(undefined)).toBe(false);
});
it('accepts each known severity and rejects unknown / non-strings', () => {
for (const s of DISASTER_SEVERITIES) expect(isDisasterSeverity(s)).toBe(true);
expect(isDisasterSeverity('extreme')).toBe(false);
expect(isDisasterSeverity('Minor')).toBe(false); // case-sensitive
expect(isDisasterSeverity('')).toBe(false);
expect(isDisasterSeverity(3)).toBe(false);
expect(isDisasterSeverity(undefined)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// validateDisasterEvents
// ---------------------------------------------------------------------------
describe('validateDisasterEvents', () => {
const valid: DisasterEvent = { type: 'earthquake', target: 'uk', severity: 'severe' };
it('accepts the { events: [...] } shape', () => {
expect(validateDisasterEvents({ events: [valid] })).toEqual([valid]);
});
it('accepts a bare array (lenient about the wrapper)', () => {
expect(validateDisasterEvents([valid])).toEqual([valid]);
});
it('drops an event with an unknown type, keeping the valid one', () => {
const out = validateDisasterEvents({
events: [
{ type: 'tsunami', target: 'uk', severity: 'minor' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('drops an event with a missing type', () => {
const out = validateDisasterEvents({
events: [{ target: 'uk', severity: 'minor' }, valid],
});
expect(out).toEqual([valid]);
});
it('drops an event with an unknown severity, keeping the valid one', () => {
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: 'uk', severity: 'extreme' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('drops an event with a missing severity', () => {
const out = validateDisasterEvents({
events: [{ type: 'plague', target: 'uk' }, valid],
});
expect(out).toEqual([valid]);
});
it('drops an event with an empty / whitespace target', () => {
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: ' ', severity: 'minor' },
{ type: 'plague', target: '', severity: 'minor' },
valid,
],
});
expect(out).toEqual([valid]);
});
it('caps a non-empty detail at 300 chars and keeps a short one', () => {
const long = 'x'.repeat(500);
const out = validateDisasterEvents({
events: [
{ type: 'plague', target: 'uk', severity: 'minor', detail: long },
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
],
});
expect(out).toEqual([
{ type: 'plague', target: 'uk', severity: 'minor', detail: 'x'.repeat(300) },
{ type: 'flood', target: 'de', severity: 'minor', detail: 'a short note' },
]);
});
it('omits the detail key entirely when detail is empty / missing', () => {
const out = validateDisasterEvents({
events: [
{ type: 'flood', target: 'uk', severity: 'minor', detail: '' },
{ type: 'flood', target: 'de', severity: 'minor' },
],
});
expect(out).toEqual([
{ type: 'flood', target: 'uk', severity: 'minor' },
{ type: 'flood', target: 'de', severity: 'minor' },
]);
expect(out[0]).not.toHaveProperty('detail');
});
it('drops non-object event entries', () => {
const out = validateDisasterEvents({ events: ['not-an-object', 7, null, valid] });
expect(out).toEqual([valid]);
});
it('yields { events: [] } (here, []) for a missing / malformed top-level value', () => {
expect(validateDisasterEvents(undefined)).toEqual([]);
expect(validateDisasterEvents(null)).toEqual([]);
expect(validateDisasterEvents('nope')).toEqual([]);
expect(validateDisasterEvents(5)).toEqual([]);
expect(validateDisasterEvents({})).toEqual([]);
expect(validateDisasterEvents({ events: 'not-an-array' })).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// applyDisasterEvent — the deterministic dip
// ---------------------------------------------------------------------------
describe('applyDisasterEvent', () => {
it('a nation target: dips the nation AND every region it owns, then re-derives (identical values pass through)', () => {
// uk owns three identical regions; all stats identical → the re-derived
// aggregate equals the dipped values exactly.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2', 'g3']) },
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50), g3: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
// earthquake moderate = stability -4, economy -4 (military / population untouched)
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
for (const k of ['g1', 'g2', 'g3']) expect(state.regionStats[k]).toEqual(RS(50, 46, 46, 50));
});
it('a region target: dips ONLY the named region (the sibling region is untouched)', () => {
// uk owns g1 and g2 (identical). Target the region key g1 → only g1 dips.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
{ g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'wildfire', target: 'g1', severity: 'severe' });
// wildfire severe = economy -7 only
expect(state.regionStats.g1).toEqual(RS(50, 43, 50, 50));
expect(state.regionStats.g2).toEqual(RS(50, 50, 50, 50)); // untouched
// re-derive (equal weights, min-biased median) → the nation takes the dipped value
expect(state.nations.uk.stats).toEqual(NS(50, 43, 50, 50));
});
it('a region target resolved by a validKeys-gated key hits its sole owner', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(
state,
{ type: 'famine', target: 'g1', severity: 'moderate' },
undefined,
new Set(['g1']),
);
// famine moderate = economy -4, stability -4
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
});
it('clamps every dip at 0 (never negative)', () => {
// low stability + military; a severe plague (stability -9, military -6) floors both.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(3, 50, 3, 50), ['g1']) },
{ g1: RS(3, 50, 3, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'uk', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(0, 50, 0, 50));
expect(state.regionStats.g1).toEqual(RS(0, 50, 0, 50));
});
it('a 0-owner region is untargetable (the event is dropped, nothing dips)', () => {
// 'x1' is owned by nobody; no validKeys supplied, so the owners check drops it.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'x1', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
});
it('a region with 2+ owners is untargetable (the event is dropped, neither dips)', () => {
// corrupt double-ownership of 'y1'
const state = mkState(
{
uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['y1']),
de: mkNation('de', 'DE', NS(60, 60, 60, 60), ['y1']),
},
{ y1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'y1', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.nations.de.stats).toEqual(NS(60, 60, 60, 60)); // untouched
});
it('an unknown region key (gated by validKeys) is dropped', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
// 'zzz' is not in the manifest → dropped before the owners check.
applyDisasterEvent(state, { type: 'plague', target: 'zzz', severity: 'severe' }, undefined, new Set(['g1']));
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
});
it('an unknown nation reference is dropped (no throw, no dip)', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'plague', target: 'ghost', severity: 'severe' });
expect(state.nations.uk.stats).toEqual(NS(50, 50, 50, 50));
expect(state.regionStats.g1).toEqual(RS(50, 50, 50, 50));
});
it('an ambiguous nation reference (two share the name) is dropped', () => {
const state = mkState(
{
a: mkNation('a', 'Same', NS(50, 50, 50, 50), ['g1']),
b: mkNation('b', 'Same', NS(70, 70, 70, 70), ['d1']),
},
{ g1: RS(50, 50, 50, 50), d1: RS(70, 70, 70, 70) },
);
applyDisasterEvent(state, { type: 'plague', target: 'Same', severity: 'severe' });
expect(state.nations.a.stats).toEqual(NS(50, 50, 50, 50)); // untouched
expect(state.nations.b.stats).toEqual(NS(70, 70, 70, 70)); // untouched
});
it('resolves a nation by (case-insensitive) name, not just id', () => {
const state = mkState(
{ uk: mkNation('uk', 'United Kingdom', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'epidemic', target: 'united kingdom', severity: 'moderate' });
// epidemic moderate = stability -4, military -2
expect(state.nations.uk.stats).toEqual(NS(48, 50, 46, 50));
expect(state.regionStats.g1).toEqual(RS(48, 50, 46, 50));
});
it('a 0-region nation keeps its directly-dipped stats (no re-derive to zero)', () => {
const iso = mkNation('iso', 'Iso', NS(50, 50, 50, 50), []);
const state = mkState({ iso }, {});
applyDisasterEvent(state, { type: 'epidemic', target: 'iso', severity: 'moderate' });
// epidemic moderate = stability -4, military -2 — the direct dip persists
expect(state.nations.iso.stats).toEqual(NS(48, 50, 46, 50));
expect(state.regionStats).toEqual({});
});
it('a region with no regionStats entry is skipped (the nation still dips + re-derives from its other regions)', () => {
// uk owns g1 (has stats) and g2 (no entry). Target the nation → the dip
// mirrors to g1 only (g2 has no entry to dip); re-derive uses g1 alone.
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1', 'g2']) },
{ g1: RS(50, 50, 50, 50) },
);
applyDisasterEvent(state, { type: 'earthquake', target: 'uk', severity: 'moderate' });
expect(state.regionStats.g1).toEqual(RS(50, 46, 46, 50));
expect(state.regionStats).not.toHaveProperty('g2'); // never created
expect(state.nations.uk.stats).toEqual(NS(50, 46, 46, 50));
});
});
// ---------------------------------------------------------------------------
// The Ability object
// ---------------------------------------------------------------------------
describe('disasters (Ability)', () => {
it('owns the "disasters" field and declares NO durable state fields (turn-transient)', () => {
expect(disasters.field).toBe('disasters');
expect(disasters.stateFields).toBeUndefined();
});
it('default() and stub() are fresh, empty { events: [] } values', () => {
expect(disasters.default()).toEqual({ events: [] });
expect(disasters.stub()).toEqual({ events: [] });
// fresh on every call (never a shared mutable reference)
expect(disasters.default()).not.toBe(disasters.default());
expect(disasters.default().events).not.toBe(disasters.default().events);
expect(disasters.stub()).not.toBe(disasters.stub());
});
it('isMeaningful is true iff there is at least one event', () => {
expect(disasters.isMeaningful({ events: [] })).toBe(false);
expect(disasters.isMeaningful({ events: [{ type: 'plague', target: 'uk', severity: 'minor' }] })).toBe(
true,
);
});
it('payload() is always empty (the layer is turn-transient)', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
expect(disasters.payload({ state })).toEqual({});
});
it('validate() wraps the low-level validator into the { events } field shape', () => {
const valid = { type: 'flood', target: 'uk', severity: 'minor' } as DisasterEvent;
expect(disasters.validate({ events: [valid] })).toEqual({ events: [valid] });
expect(disasters.validate(undefined)).toEqual({ events: [] });
expect(disasters.validate([valid])).toEqual({ events: [valid] }); // bare array leniency
});
it('apply() wires ctx.geoOf + ctx.validRegionKeys through to the dip', () => {
const state = mkState(
{ uk: mkNation('uk', 'UK', NS(50, 50, 50, 50), ['g1']) },
{ g1: RS(50, 50, 50, 50) },
);
const ctx = mkCtx(state, new Set(['g1']));
const data: DisastersOps = { events: [{ type: 'volcanic', target: 'g1', severity: 'moderate' }] };
disasters.apply(state, data, ctx);
// volcanic moderate = economy -4, stability -3
expect(state.regionStats.g1).toEqual(RS(50, 46, 47, 50));
expect(state.nations.uk.stats).toEqual(NS(50, 46, 47, 50));
});
});
+259
View File
@@ -0,0 +1,259 @@
// The DISASTERS & PANDEMICS ability (v0.41.0, #63) as a modular LLM-ability
// (v0.40.0, #67). Natural and man-made catastrophes — earthquakes, floods,
// volcanic eruptions, wildfires, famines, plagues, and epidemics — hit a
// nation or a single region each turn. 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 layer. Each event carries a
// `type` and a `severity`, and the engine applies a DETERMINISTIC stat dip
// chosen from the `(type, severity)` lookup table below (no hidden
// randomness — the LLM chooses the type and severity, the table decides the
// magnitude). The dip lands on the target nation's `stats` (clamped 0–100)
// AND on the target's regions (`regionStats` — the single named region when
// the target was a region key, otherwise every region the target owns), and
// the target's derived `stats` are re-derived afterwards (the same 0-region /
// no-entry guards as hybrid / economy / industry, so a 0-region nation keeps
// its directly-mutated value and the dip persists to the next turn). The
// target is a nation (resolved by the shared `resolveNationRef`) or a region
// key (which hits its SOLE owner); a key with 0 or 2+ owners is untargetable.
// There is no acting-nation (`from`) field on a disaster — a catastrophe is
// not an attack, so the hybrid's self-target drop does not apply. Malformed
// events and unresolvable targets are dropped, never thrown.
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type {
DisasterEvent,
DisasterSeverity,
DisasterType,
} from '../../shared/protocol';
import type { GameState, Nation, NationStats, StatKey } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/** The seven disaster types a turn can declare (v0.41.0, #63). */
export const DISASTER_TYPES: readonly DisasterType[] = [
'earthquake',
'flood',
'volcanic',
'wildfire',
'famine',
'plague',
'epidemic',
];
/** The three severity levels a disaster event can carry (v0.41.0, #63). */
export const DISASTER_SEVERITIES: readonly DisasterSeverity[] = ['minor', 'moderate', 'severe'];
const VALID_DISASTER_TYPES = new Set<string>(DISASTER_TYPES);
const VALID_SEVERITIES = new Set<string>(DISASTER_SEVERITIES);
/** True when `t` is one of the seven known disaster types (v0.41.0, #63). */
export function isDisasterType(t: unknown): t is DisasterType {
return typeof t === 'string' && VALID_DISASTER_TYPES.has(t);
}
/** True when `s` is one of the three known disaster severities (v0.41.0, #63). */
export function isDisasterSeverity(s: unknown): s is DisasterSeverity {
return typeof s === 'string' && VALID_SEVERITIES.has(s);
}
/**
* (v0.41.0, #63) The DETERMINISTIC per-`(type, severity)` stat dip of a
* disaster event. Each pair maps to a small set of signed (negative) stat
* deltas: no hidden randomness — the LLM picks the type and severity, the
* table decides the magnitude. Population is never touched (a disaster hurts
* military / economy / stability, never the head-count). Every (type,
* severity) combination dips at least one stat (none is purely
* informational).
*/
export const DISASTER_EFFECTS: Record<DisasterType, Record<DisasterSeverity, Partial<Record<StatKey, number>>>> = {
earthquake: {
minor: { stability: -2, economy: -2 },
moderate: { stability: -4, economy: -4 },
severe: { stability: -7, economy: -7 },
},
flood: {
minor: { economy: -2, stability: -1 },
moderate: { economy: -3, stability: -2 },
severe: { economy: -6, stability: -4 },
},
volcanic: {
minor: { economy: -2, stability: -2 },
moderate: { economy: -4, stability: -3 },
severe: { economy: -7, stability: -6 },
},
wildfire: {
minor: { economy: -2 },
moderate: { economy: -4 },
severe: { economy: -7 },
},
famine: {
minor: { economy: -2, stability: -2 },
moderate: { economy: -4, stability: -4 },
severe: { economy: -7, stability: -7 },
},
plague: {
minor: { stability: -3, military: -2 },
moderate: { stability: -5, military: -3 },
severe: { stability: -9, military: -6 },
},
epidemic: {
minor: { stability: -2, military: -1 },
moderate: { stability: -4, military: -2 },
severe: { stability: -7, military: -4 },
},
};
/**
* (v0.41.0, #63) Validate the raw `disasters` field of a turn into a safe
* list of {@link DisasterEvent}s. Defensive like the rest of the parser — never throws.
* The raw value may be the object `{ events: [...] }` (the TurnResult shape)
* or a bare array (be lenient about either). Each event needs a known `type`
* (one of {@link DISASTER_TYPES}), a known `severity` (one of
* {@link DISASTER_SEVERITIES}), and a non-empty `target` (a nation id/name or
* a region key, capped at 200 chars); a non-empty `detail` is capped at 300
* chars. An event with an unknown / missing `type`, an unknown / missing
* `severity`, or an empty `target` is dropped. Resolution of `target` to a
* concrete nation (or a region's sole owner) happens at apply time
* (`applyDisasterEvent`), not here. A missing / malformed top-level value
* yields `{ events: [] }`.
*/
export function validateDisasterEvents(raw: unknown): DisasterEvent[] {
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).events;
if (Array.isArray(inner)) list = inner;
}
if (!list) return [];
const out: DisasterEvent[] = [];
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isDisasterType(o.type)) continue; // unknown / missing type: drop
if (!isDisasterSeverity(o.severity)) continue; // unknown / missing severity: drop
const target = str(o.target, 200);
if (!target) continue; // empty / missing target: drop
const detail = str(o.detail, 300);
out.push({
type: o.type,
target,
severity: o.severity,
...(detail ? { detail } : {}),
});
}
return out;
}
/**
* (v0.41.0, #63) Resolve a disaster event's `target` (a nation id/name OR a
* region key) to the concrete nation it affects. A nation ref resolves via
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
* with zero or 2+ owners can't be a valid target). Returns the affected
* nation plus the specific region key (when the target was a region) so the
* dip can land on just that region's entry. `validKeys` (when supplied)
* gates the region-key path; a ref that is neither a known nation nor a
* valid region key returns `null`. Pure; never throws.
*
* NOTE: there is no acting-nation (`from`) field on a disaster — a
* catastrophe is not an attack, so (unlike the hybrid layer) a self-target
* drop does NOT apply: an earthquake can hit the nation it is declared
* against.
*/
function resolveDisasterTarget(
state: GameState,
target: string,
validKeys?: ReadonlySet<string>,
): { nation: Nation; regionKey?: string } | null {
const id = resolveNationRef(state, target);
if (id !== null) {
const n = state.nations[id];
return n ? { nation: n } : null;
}
const key = (target ?? '').trim();
if (!key) return null;
if (validKeys && !validKeys.has(key)) return null; // unknown region key
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
return { nation: owners[0], regionKey: key };
}
/** Apply each signed dip in `dips` to a stats record in place (clamped). */
function applyDips(stats: NationStats, dips: Partial<Record<StatKey, number>>): void {
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') stats[k] = clampStat(stats[k] + d);
});
}
/**
* (v0.41.0, #63) Apply ONE disaster event to the (cloned) `next` state,
* mutating it in place and never throwing. Resolution order:
* 1. `target` must resolve to a concrete nation (a nation ref, or a region
* key's sole owner); an unresolvable target (unknown / ambiguous ref, a
* region with 0 or 2+ owners, or an unknown region key) drops the event.
* Then the deterministic `(type, severity)` dip from {@link DISASTER_EFFECTS}
* is applied:
* - the target nation's `stats` is dipped on each affected stat (clamped
* 0–100);
* - the dip is mirrored to the target's regions — the single named region
* when the target was a region key, otherwise every region the target
* owns (regions with no `regionStats` entry are skipped);
* - the target nation's derived `stats` are re-derived afterwards (the
* same 0-region / no-entry guards as the main recompute, so a 0-region
* nation keeps its directly-mutated value and a dip persists).
* A disaster never moves territory (sole ownership is untouched) and never
* touches `population`.
*/
export function applyDisasterEvent(
next: GameState,
ev: DisasterEvent,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
): void {
const target = resolveDisasterTarget(next, ev.target, validKeys);
if (target === null) return; // unresolvable target: drop
const dips = DISASTER_EFFECTS[ev.type][ev.severity];
if (!dips) return; // defensive: unknown (type, severity) pair
applyDips(target.nation.stats, dips);
if (target.regionKey !== undefined) {
const rs = next.regionStats[target.regionKey];
if (rs) applyDips(rs, dips);
} else {
for (const key of target.nation.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped
applyDips(rs, dips);
}
}
// Re-derive the target nation's aggregate stats so the dip persists (the
// main recompute ran earlier this turn; without this the dip would be
// washed out next turn). Same 0-region / no-entry guards as hybrid /
// economy: a nation with no region stats entry keeps its directly-mutated
// `stats`.
if (target.nation.territory.length > 0 && target.nation.territory.some((k) => next.regionStats[k])) {
target.nation.stats = deriveNationStats(target.nation.territory, next.regionStats, geoOf);
}
}
export const disasters: Ability<'disasters'> = {
field: 'disasters',
default: () => ({ events: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ events }` object (mirrors the historical TurnResult shape).
validate: (raw) => ({ events: validateDisasterEvents(raw) }),
apply: (state, data, ctx) => {
for (const ev of data.events) {
applyDisasterEvent(state, ev, ctx.geoOf, ctx.validRegionKeys);
}
},
schemaLine: () =>
' "disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},',
constraint: () =>
'- 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).',
isMeaningful: (data) => data.events.length > 0,
stub: () => ({ events: [] }),
// 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: () => ({}),
};
@@ -39,6 +39,9 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -64,6 +67,9 @@ HARD CONSTRAINTS
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "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":"<nation id or name>","to":"<new form of government>"}: 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).
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" 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.
+6
View File
@@ -39,6 +39,9 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},
"leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},
"ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},
"disasters": {"events":[{"type":"<earthquake|flood|volcanic|wildfire|famine|plague|epidemic>","target":"<nation id or name, or region key>","severity":"<minor|moderate|severe>","detail":"<short detail>"}]},
"migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},
"sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
@@ -64,6 +67,9 @@ HARD CONSTRAINTS
- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man's land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region's owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".
- Leaders ("leaders") name each nation's current head of state and change it. Each change is {"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}: "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":"<nation id or name>","to":"<new form of government>"}: 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).
- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" 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.
+2 -1
View File
@@ -205,5 +205,6 @@
"regionKey": "a-r1",
"type": "industry"
}
]
],
"seaZones": []
}
+52 -4
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 8 per-feature LLM abilities.
// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea
// 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,
@@ -22,13 +22,18 @@ import { industry } from './industry';
import { nuclear } from './nuclear';
import { leaders } from './leaders';
import { ideology } from './ideology';
import { disasters } from './disasters';
import { migration } from './migration';
import { sea } from './sea';
import type { Ability, AbilityField } from './types';
/**
* The 8 per-feature LLM abilities in their canonical apply order.
* The 11 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<AbilityField>[] = [
takeovers,
@@ -39,6 +44,9 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
nuclear,
leaders,
ideology,
disasters,
migration,
sea,
];
// ── type re-exports ────────────────────────────────────────────────────────────
@@ -125,3 +133,43 @@ export {
export {
validateIdeologyOps,
} from './ideology';
// ── disasters ────────────────────────────────────────────────────────────────
export {
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
DISASTER_TYPES,
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';
// ── sea ──────────────────────────────────────────────────────────────────────
export {
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
} from './sea';
+572
View File
@@ -0,0 +1,572 @@
// 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<string, RelationStatus> = {},
): 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<string, Nation>,
regionStats: Record<string, RegionStats>,
): 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: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
// ---------------------------------------------------------------------------
// 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<string, RelationStatus>): 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));
});
});
+325
View File
@@ -0,0 +1,325 @@
// The MIGRATION & REFUGEES ability (v0.42.0, #64) as a modular LLM-ability
// (v0.40.0, #67). People moving between nations each turn: ordinary migration,
// refugee flows (fleeing a war or disaster), a spreading diaspora, and an
// exodus (people leaving with no destination). Unlike most other abilities this
// one is **turn-transient**: it owns NO `GameState` field (no durable list, no
// `db.ts` change) — exactly like the hybrid and disasters layers. Each op
// carries a `kind`, a source `from` (the nation people leave), a destination
// `to` (required for every kind except `exodus`), and a `scale` (small /
// moderate / large). The engine applies a DETERMINISTIC effect chosen from the
// per-`scale` tables below (no hidden randomness — the LLM chooses the kind and
// scale, the tables decide the magnitude): the source LOSES a small amount of
// population and economy, the destination GAINS a small amount of population and
// economy (a `large` influx additionally strains the destination's stability),
// both clamped to 0–100, mirrored to each side's regions (`regionStats`), and
// each side's derived `stats` re-derived afterwards (the same 0-region /
// no-entry guards as hybrid / economy / disasters, so a 0-region nation keeps
// its directly-mutated value and the effect persists to the next turn). The
// from↔to relation then moves one step toward the positive end of the
// ally/neutral/rival/hostile ladder (`shiftRelationTowardPositive`) — capped at
// `ally` for `refugees` (hosting refugees builds the strongest goodwill),
// `neutral` for `migration` / `diaspora` (milder ties), and not at all for
// `exodus`; a `war` relation is never touched (the nudge neither fabricates nor
// escalates a war). Both `from` and `to` are resolved by the shared
// `resolveNationRef` (id first, then a case-insensitive unique name); an
// unknown / ambiguous ref, a missing `to` (for a non-`exodus` kind), or a
// self-flow (`from` and `to` the same nation) drops the whole op. It never
// moves territory (sole ownership is untouched).
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type { MigrationOp, MigrationScale, MigrationKind } from '../../shared/protocol';
import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/** The four kinds of population flow a turn can declare (v0.42.0, #64). */
export const MIGRATION_KINDS: readonly MigrationKind[] = [
'migration',
'refugees',
'diaspora',
'exodus',
];
/** The three flow sizes a migration op can carry (v0.42.0, #64). */
export const MIGRATION_SCALES: readonly MigrationScale[] = ['small', 'moderate', 'large'];
const VALID_KINDS = new Set<string>(MIGRATION_KINDS);
const VALID_SCALES = new Set<string>(MIGRATION_SCALES);
/** True when `k` is one of the four known migration kinds (v0.42.0, #64). */
export function isMigrationKind(k: unknown): k is MigrationKind {
return typeof k === 'string' && VALID_KINDS.has(k);
}
/** True when `s` is one of the three known migration scales (v0.42.0, #64). */
export function isMigrationScale(s: unknown): s is MigrationScale {
return typeof s === 'string' && VALID_SCALES.has(s);
}
/**
* (v0.42.0, #64) The DETERMINISTIC per-`scale` stat dip applied to the SOURCE
* nation of a migration flow (the nation people leave). Each scale maps to a
* small set of signed (negative) stat deltas: the source loses population and
* economy. No hidden randomness — the LLM picks the scale, the table decides
* the magnitude. Military and stability are untouched by the source dip.
*/
export const MIGRATION_SOURCE_EFFECTS: Record<MigrationScale, Partial<Record<StatKey, number>>> = {
small: { population: -1, economy: -1 },
moderate: { population: -2, economy: -2 },
large: { population: -4, economy: -3 },
};
/**
* (v0.42.0, #64) The DETERMINISTIC per-`scale` stat gain applied to the
* DESTINATION nation of a migration flow (the nation people arrive in). Each
* scale maps to a small set of signed (positive) stat deltas: the destination
* gains population and economy. No hidden randomness. Military and stability
* are untouched by the base gain (the `large`-scale stability strain is applied
* separately, see {@link LARGE_INFLUX_STABILITY_DIP}).
*/
export const MIGRATION_DESTINATION_EFFECTS: Record<MigrationScale, Partial<Record<StatKey, number>>> =
{
small: { population: 1, economy: 1 },
moderate: { population: 2, economy: 2 },
large: { population: 3, economy: 3 },
};
/**
* (v0.42.0, #64) The extra STABILITY dip applied to the destination of a
* `large`-scale influx only — a big population surge strains a society even as
* it grows. Added on top of the base destination gain.
*/
export const LARGE_INFLUX_STABILITY_DIP = -2;
/**
* The positive-relations ladder, strongest (most positive) to weakest
* (least positive) — the same four non-war statuses, in the order a relation
* moves when it is nudged TOWARD the positive end. A missing relation is
* treated as `neutral`; `war` is never touched (a war is its own entity — a
* population flow neither fabricates nor escalates one). The `cap` in
* {@link shiftRelationTowardPositive} is a member of this array.
*/
const POSITIVE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
/**
* (v0.42.0, #64) Nudge the bilateral relation between nations `a` and `b`
* ONE step toward the POSITIVE end (toward `ally`) of the
* {@link POSITIVE_ORDER} ladder, applied symmetrically to both directions, but
* never past the given `cap`. A missing relation starts at `neutral`; a `war`
* relation is never touched (a population flow neither fabricates nor
* escalates a war — a warring pair is left exactly as it is). Mutates `state`
* in place (the caller passes a clone — applyTurnResult does). Never throws.
*
* `cap` is the most-positive status the nudge may reach:
* - `refugees` → `ally` (hosting refugees builds the strongest goodwill)
* - `migration` / `diaspora` → `neutral` (milder ties)
* - `exodus` → no nudge at all
*/
export function shiftRelationTowardPositive(
state: GameState,
a: string,
b: string,
cap: RelationStatus,
): void {
const na = state.nations[a];
const nb = state.nations[b];
if (!na || !nb) return;
const capIdx = POSITIVE_ORDER.indexOf(cap);
if (capIdx === -1) return; // unknown cap: no-op (defensive)
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
if (cur === 'war') return null; // never touch a war relation
const s = cur ?? 'neutral'; // a missing relation is neutral
const i = POSITIVE_ORDER.indexOf(s);
if (i === -1) return 'neutral'; // defensive: unknown status
if (i <= capIdx) return s; // already at or more positive than the cap: no change
return POSITIVE_ORDER[i - 1]; // one step toward the positive end
};
const stepA = step(na.relations[b]);
if (stepA !== null) na.relations[b] = stepA;
const stepB = step(nb.relations[a]);
if (stepB !== null) nb.relations[a] = stepB;
}
/**
* (v0.42.0, #64) Validate the raw `migration` field of a turn into a safe
* list of {@link MigrationOp}s. Defensive like the rest of the parser — never
* throws. The raw value may be the object `{ ops: [...] }` (the TurnResult
* shape) or a bare array (be lenient about either). Each op needs a known
* `kind` (one of {@link MIGRATION_KINDS}), a known `scale` (one of
* {@link MIGRATION_SCALES}), a non-empty `from` (the source nation, capped at
* 200 chars), and — for every kind except `exodus` — a non-empty `to`
* (the destination nation, capped at 200 chars); a non-empty `detail` is capped
* at 300 chars. An op with an unknown / missing `kind`, an unknown / missing
* `scale`, an empty `from`, or (for a non-`exodus` kind) an empty `to` is
* dropped. Resolution of `from` / `to` to concrete nation ids happens at apply
* time (`applyMigrationOp`), not here. A missing / malformed top-level value
* yields `{ ops: [] }`.
*/
export function validateMigrationOps(raw: unknown): MigrationOp[] {
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).ops;
if (Array.isArray(inner)) list = inner;
}
if (!list) return [];
const out: MigrationOp[] = [];
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isMigrationKind(o.kind)) continue; // unknown / missing kind: drop
if (!isMigrationScale(o.scale)) continue; // unknown / missing scale: drop
const from = str(o.from, 200);
if (!from) continue; // empty / missing source: drop
const detail = str(o.detail, 300);
if (o.kind === 'exodus') {
// an exodus has no destination — `to` is optional (and ignored)
out.push({
kind: o.kind,
from,
scale: o.scale,
...(detail ? { detail } : {}),
});
} else {
const to = str(o.to, 200);
if (!to) continue; // non-exodus kind needs a destination: drop
out.push({
kind: o.kind,
from,
to,
scale: o.scale,
...(detail ? { detail } : {}),
});
}
}
return out;
}
/**
* Apply a set of signed stat `dips` to a nation's `stats` AND to every region
* it owns that has a `regionStats` entry (clamped to 0–100), then re-derive
* the nation's aggregate `stats` as the area×population-weighted median of its
* regions — the same 0-region / no-entry guards as the hybrid / economy /
* disasters layers, so a 0-region nation keeps its directly-mutated value and
* the effect persists to the next turn. Mutates in place; never throws.
*/
function applyNationDips(
nation: Nation,
dips: Partial<Record<StatKey, number>>,
regionStats: GameState['regionStats'],
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') nation.stats[k] = clampStat(nation.stats[k] + d);
});
for (const key of nation.territory) {
const rs = regionStats[key];
if (!rs) continue; // a key with no entry is skipped
(Object.keys(dips) as StatKey[]).forEach((k) => {
const d = dips[k];
if (typeof d === 'number') rs[k] = clampStat(rs[k] + d);
});
}
// Re-derive the nation's aggregate stats so the effect persists (the main
// recompute ran earlier this turn; without this the change would be washed
// out next turn). A nation with no regionStats entry keeps its
// directly-mutated `stats`.
if (nation.territory.length > 0 && nation.territory.some((k) => regionStats[k])) {
nation.stats = deriveNationStats(nation.territory, regionStats, geoOf);
}
}
/**
* (v0.42.0, #64) Apply ONE migration / refugee op to the (cloned) `next` state,
* mutating it in place and never throwing. Resolution order:
* 1. `from` must resolve to a nation (id first, then a unique name); an
* unknown / ambiguous source drops the op.
* 2. For every kind except `exodus`, `to` must resolve to a nation distinct
* from `from` (a missing / unknown / ambiguous destination, or a
* self-flow, drops the op); `exodus` has no destination and skips this.
* Then the deterministic per-`scale` effects from
* {@link MIGRATION_SOURCE_EFFECTS} / {@link MIGRATION_DESTINATION_EFFECTS}
* are applied:
* - the SOURCE nation loses population and economy (all four kinds,
* including `exodus`) — for a non-`exodus` op this is applied only AFTER
* the destination is validated, so a self-flow or a bad destination drops
* the op entirely and never touches the source;
* - for a non-`exodus` kind the DESTINATION gains population and economy,
* and a `large` influx additionally strains its stability by
* {@link LARGE_INFLUX_STABILITY_DIP};
* - each affected nation's derived `stats` are re-derived afterwards (the
* same 0-region / no-entry guards, so the effect persists);
* - for a non-`exodus` kind the from↔to relation moves one step toward the
* positive end of the ladder (cap `ally` for `refugees`, `neutral` for
* `migration` / `diaspora`; a `war` relation is left untouched).
* The op never moves territory (sole ownership is untouched) and never creates
* or escalates a war.
*/
export function applyMigrationOp(
next: GameState,
op: MigrationOp,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
const from = resolveNationRef(next, op.from);
if (from === null) return; // unknown / ambiguous source: drop
const source = next.nations[from];
if (!source) return; // defensive: resolved id has no nation record
if (op.kind === 'exodus') {
// An exodus has no destination: the source loses population and economy
// and that is all (no gain, no relation nudge).
applyNationDips(source, MIGRATION_SOURCE_EFFECTS[op.scale], next.regionStats, geoOf);
return;
}
// Non-exodus: resolve the destination FIRST and drop the WHOLE op (including
// the source dip) when it is missing / unknown / ambiguous or a self-flow —
// so a self-flow or a bad destination never touches the source.
const to = op.to;
if (!to) return; // defensive: a non-exodus op should always have a `to`
const destId = resolveNationRef(next, to);
if (destId === null || destId === from) return; // unknown / self destination: drop
const dest = next.nations[destId];
if (!dest) return; // defensive
// The source loses population and economy (every non-exodus kind reaches
// here; the exodus path already applied its source dip and returned).
applyNationDips(source, MIGRATION_SOURCE_EFFECTS[op.scale], next.regionStats, geoOf);
// The destination gains population and economy; a large influx also strains
// its stability.
const destDips: Partial<Record<StatKey, number>> = { ...MIGRATION_DESTINATION_EFFECTS[op.scale] };
if (op.scale === 'large') {
destDips.stability = (destDips.stability ?? 0) + LARGE_INFLUX_STABILITY_DIP;
}
applyNationDips(dest, destDips, next.regionStats, geoOf);
// The from↔to relation moves one step toward the positive end, capped by
// kind (a `war` relation is left untouched by shiftRelationTowardPositive).
const cap: RelationStatus = op.kind === 'refugees' ? 'ally' : 'neutral';
shiftRelationTowardPositive(next, from, destId, cap);
}
export const migration: Ability<'migration'> = {
field: 'migration',
default: () => ({ ops: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ ops }` object (mirrors the historical TurnResult shape).
validate: (raw) => ({ ops: validateMigrationOps(raw) }),
apply: (state, data, ctx) => {
for (const op of data.ops) {
applyMigrationOp(state, op, ctx.geoOf);
}
},
schemaLine: () =>
' "migration": {"ops":[{"kind":"<migration|refugees|diaspora|exodus>","from":"<nation id or name>","to":"<nation id or name>","scale":"<small|moderate|large>","detail":"<short detail>"}]},',
constraint: () =>
'- Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination\'s stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event).',
isMeaningful: (data) => data.ops.length > 0,
stub: () => ({ ops: [] }),
// The layer is turn-transient — it contributes nothing to the user payload
// (no durable state to show the model), so the payload is always empty.
payload: () => ({}),
};
+87 -14
View File
@@ -3,13 +3,16 @@
// These tests pin the refactor's two load-bearing guarantees:
//
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
// prompt the refactored engine builds are BYTE-IDENTICAL to the committed
// golden fixtures in ./goldens/ (captured in P1 from the pre-refactor
// engine). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here.
// prompt the engine builds are BYTE-IDENTICAL to the committed golden
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
// system goldens were regenerated in v0.41.0 when the disasters ability
// was added). Any transcription drift in an ability's schemaLine /
// constraint / payload is caught here. The USER prompt golden is unchanged
// by the disasters feature (its payload is always empty).
//
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 8 abilities; each ability's default() equals the canonical
// list of the 11 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
@@ -26,28 +29,53 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
activeWarPairs,
applyDisasterEvent,
applyIndustryEffects,
applyMigrationOp,
atWar,
buildSystemPrompt,
buildUserPrompt,
DISASTER_EFFECTS,
DISASTER_SEVERITIES,
DISASTER_TYPES,
freshNationId,
freshWarId,
HYBRID_STAT_EFFECT,
isDisasterSeverity,
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,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateMigrationOps,
validateNuclearOps,
validateTakeovers,
validateWarOps,
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
} from '../engine';
import { createPresetStore } from '../presets';
import { ABILITIES } from './index';
@@ -61,6 +89,7 @@ import type {
RegionResources,
RelationStatus,
RoundItem,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
@@ -134,6 +163,8 @@ const state: GameState = {
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
// --- buildUserPrompt arguments: every optional branch exercised. ---
@@ -170,6 +201,7 @@ const nationResources: Record<string, RegionResources> = {
};
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
const seaZones: SeaZoneClaim[] = [];
/**
* Assert the current prompt output is byte-identical to the committed golden —
@@ -206,6 +238,9 @@ function fullDefaultTurn(): TurnResult {
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
sea: { control: [] },
markers: { add: [], remove: [] },
};
}
@@ -229,11 +264,17 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] };
case 'ideology':
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' }] };
case 'sea':
return { control: [{ zone: 'pacific', by: 'a1' }] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 8-ability order (takeovers → ideology)', () => {
it('is the canonical 11-ability order (takeovers → sea)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
@@ -243,13 +284,16 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'nuclear',
'leaders',
'ideology',
'disasters',
'migration',
'sea',
]);
});
it('lists 8 distinct fields, each a key of TurnResult', () => {
it('lists 11 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(8);
expect(new Set(fields).size).toBe(8);
expect(fields).toHaveLength(11);
expect(new Set(fields).size).toBe(11);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
@@ -279,6 +323,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
nationResources,
industryProjects,
nuclear,
seaZones,
),
);
});
@@ -308,13 +353,13 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('declares exactly the durable GameState fields the db migration must default', () => {
const owned: string[] = [];
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'trades', 'wars']);
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']);
});
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 9th 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 {
@@ -333,9 +378,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(9);
expect(extended.map((a) => a.constraint())).toHaveLength(9);
expect(extended[8].schemaLine()).toBe(' "custom": [],');
expect(extended.map((a) => a.schemaLine())).toHaveLength(12);
expect(extended.map((a) => a.constraint())).toHaveLength(12);
expect(extended[11].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
@@ -366,6 +411,34 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
// disasters (v0.41.0, #63)
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
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,
// sea (v0.43.0, #65)
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
+494
View File
@@ -0,0 +1,494 @@
// Unit tests for the SEA (naval / sea control) ability (v0.43.0, #65) — the
// modular, DURABLE LLM-ability that gives nations sole control over a fixed
// set of 12 named sea zones: the zone is upserted in `GameState.seaZones`
// (replace in place, contest by higher current military), and every committed
// turn the controller earns a deterministic +1 military / +1 economy bonus
// (clamped, mirrored to its regions, re-derived). The layer never touches
// `territory` / `unowned` — it is fully parallel to but independent of land.
// 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 sea op, so the apply path
// is exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import {
SEA_ZONES,
SEA_ZONE_IDS,
applySeaControl,
applySeaZoneBonuses,
isSeaZoneId,
sea,
seaZoneName,
validateSeaOps,
} from './sea';
import type {
GameState,
Nation,
NationStats,
RegionStats,
SeaZoneClaim,
} 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. */
function mkNation(
id: string,
name: string,
stats: NationStats,
territory: string[],
): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
/**
* A minimal GameState carrying the given nations (keyed by id), per-region
* stats, and an optional existing sea-zone list. Every other required field
* is at its fresh-game baseline.
*/
function mkState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
seaZones: SeaZoneClaim[] = [],
): GameState {
return {
id: 'g1',
presetId: 'test',
presetTitle: 'Test',
playerNationId: Object.keys(nations)[0] ?? 'a1',
turn: 1,
date: '1939-09-01',
nations,
events: [],
pending: [],
version: 1,
unowned: [],
pendingNationOps: [],
markers: [],
regionStats,
wars: [],
trades: [],
embargoes: [],
industry: [],
seaZones,
};
}
// ---------------------------------------------------------------------------
// SEA_ZONES / isSeaZoneId / seaZoneName
// ---------------------------------------------------------------------------
describe('SEA_ZONES / SEA_ZONE_IDS / isSeaZoneId / seaZoneName', () => {
it('exposes exactly the 12 fixed sea zones', () => {
expect(SEA_ZONES).toHaveLength(12);
const ids = SEA_ZONES.map((z) => z.id);
expect(ids).toEqual([
'north-atlantic',
'south-atlantic',
'pacific',
'indian',
'mediterranean',
'north-sea',
'baltic',
'arctic',
'black-sea',
'gulf-mexico',
'red-sea',
'sargasso',
]);
});
it('SEA_ZONE_IDS is a ReadonlySet of all 12 ids', () => {
expect(SEA_ZONE_IDS).toBeInstanceOf(Set);
expect(SEA_ZONE_IDS.size).toBe(12);
for (const z of SEA_ZONES) expect(SEA_ZONE_IDS.has(z.id)).toBe(true);
});
it('isSeaZoneId accepts each known id and rejects unknown / non-strings', () => {
for (const z of SEA_ZONES) expect(isSeaZoneId(z.id)).toBe(true);
expect(isSeaZoneId('atlantic-ocean')).toBe(false);
expect(isSeaZoneId('')).toBe(false);
});
it('seaZoneName returns the human-readable name; an unknown id is returned raw', () => {
expect(seaZoneName('pacific')).toBe('Pacific Ocean');
expect(seaZoneName('mediterranean')).toBe('Mediterranean Sea');
expect(seaZoneName('nope')).toBe('nope');
});
});
// ---------------------------------------------------------------------------
// validateSeaOps
// ---------------------------------------------------------------------------
describe('validateSeaOps', () => {
it('accepts the { control: [...] } object shape and a bare array', () => {
const op = { zone: 'pacific', by: 'a1' };
expect(validateSeaOps({ control: [op] })).toEqual({ control: [op] });
expect(validateSeaOps([op])).toEqual({ control: [op] });
});
it('drops an op with an unknown / missing zone', () => {
expect(validateSeaOps([{ zone: 'nope', by: 'a1' }])).toEqual({ control: [] });
expect(validateSeaOps([{ by: 'a1' }])).toEqual({ control: [] });
expect(validateSeaOps([{ zone: '', by: 'a1' }])).toEqual({ control: [] });
});
it('drops an op with an empty / missing `by` (the controller)', () => {
expect(validateSeaOps([{ zone: 'pacific', by: '' }])).toEqual({ control: [] });
expect(validateSeaOps([{ zone: 'pacific' }])).toEqual({ control: [] });
});
it('drops a non-object entry', () => {
expect(validateSeaOps([42, 'nope', null, { zone: 'pacific', by: 'a1' }])).toEqual({
control: [{ zone: 'pacific', by: 'a1' }],
});
});
it('a missing / malformed top-level value yields { control: [] }', () => {
expect(validateSeaOps(undefined)).toEqual({ control: [] });
expect(validateSeaOps(null)).toEqual({ control: [] });
expect(validateSeaOps('nope')).toEqual({ control: [] });
expect(validateSeaOps({ control: 'nope' })).toEqual({ control: [] });
});
it('caps `zone` at 100 and `by` at 200 chars', () => {
const longBy = 'f'.repeat(500);
const out = validateSeaOps([{ zone: 'pacific', by: longBy }]);
expect(out.control).toHaveLength(1);
expect(out.control[0].by).toHaveLength(200);
});
});
// ---------------------------------------------------------------------------
// applySeaControl
// ---------------------------------------------------------------------------
describe('applySeaControl', () => {
it('a single op sets a new zone claim', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'a1' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('replaces an existing claim in place (position preserved)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
[
{ zone: 'pacific', by: 'a1' },
{ zone: 'indian', by: 'b2' },
],
);
// b2 now claims pacific — the pacific claim moves to b2 but stays at index 0
applySeaControl(state, [{ zone: 'pacific', by: 'b2' }]);
expect(state.seaZones).toEqual([
{ zone: 'pacific', by: 'b2' },
{ zone: 'indian', by: 'b2' },
]);
});
it('appends a new zone after existing claims', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaControl(state, [{ zone: 'arctic', by: 'a1' }]);
expect(state.seaZones).toEqual([
{ zone: 'pacific', by: 'a1' },
{ zone: 'arctic', by: 'a1' },
]);
});
it('a contest: the higher current military wins', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
);
// b2 has higher military (90 > 80) → b2 wins
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]);
});
it('a contest tie: the earlier-listed op wins', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']),
},
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) },
);
// equal military (50 == 50) → a1 wins (listed first)
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('two ops for the same nation on the same zone: a single claim (no contest)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'a1' },
]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('resolves `by` by unique name (not just id)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'Alpha' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('an unresolvable `by` is dropped; if all ops on a zone drop, the zone is untouched', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']) },
{ 'a-r1': RS(80, 70, 60, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
// ghost does not exist → dropped; pacific stays as a1
applySeaControl(state, [{ zone: 'pacific', by: 'ghost' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
// a NEW zone with only an unresolvable op → zone not added
applySeaControl(state, [{ zone: 'arctic', by: 'ghost' }]);
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
});
it('an ambiguous name (two nations share it) drops the op', () => {
const state = mkState(
{
a1: mkNation('a1', 'Twin', NS(80, 70, 60, 50), ['a-r1']),
a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), ['a-r2']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'a-r2': RS(20, 20, 20, 20) },
);
applySeaControl(state, [{ zone: 'pacific', by: 'Twin' }]);
// ambiguous → dropped
expect(state.seaZones).toEqual([]);
});
it('never touches `territory` or `unowned`', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(80, 70, 60, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(90, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(80, 70, 60, 50), 'b-r1': RS(90, 40, 20, 30) },
);
applySeaControl(state, [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
]);
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.nations.b2.territory).toEqual(['b-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// applySeaZoneBonuses
// ---------------------------------------------------------------------------
describe('applySeaZoneBonuses', () => {
it('a controller earns +1 military and +1 economy (clamped, mirrored, re-derived)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
// +1 to both military and economy, mirrored to the region, re-derived
// (single region → re-derive gives back the region's value)
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
expect(state.regionStats['a-r1']).toEqual(RS(51, 51, 50, 50));
});
it('clamps to 0 and 100', () => {
// military at 99 → 100; economy at 1 → 2
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(99, 1, 50, 50), ['a-r1']) },
{ 'a-r1': RS(99, 1, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.stats.military).toBe(100);
expect(state.nations.a1.stats.economy).toBe(2);
});
it('a nation controlling several zones stacks the bonus', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[
{ zone: 'pacific', by: 'a1' },
{ zone: 'indian', by: 'a1' },
],
);
applySeaZoneBonuses(state);
// +2 to both (one per zone)
expect(state.nations.a1.stats).toEqual(NS(52, 52, 50, 50));
});
it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), []) },
{},
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
// direct mutation, no re-derive (no regions)
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
});
it('a stale controller (nation no longer in `nations`) is skipped; the claim is kept', () => {
// Build a state where the claim references a nation id that is not in `nations`
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'ghost' }],
);
applySeaZoneBonuses(state);
// ghost is not in nations → skipped; a1 is unaffected
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
// the claim is kept (not removed)
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'ghost' }]);
});
it('an empty seaZones list is a no-op', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
});
it('never touches `territory` or `unowned`', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
applySeaZoneBonuses(state);
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// The Ability object contract
// ---------------------------------------------------------------------------
describe('sea ability', () => {
it('owns the "sea" field; default() is a fresh empty { control: [] }', () => {
expect(sea.field).toBe('sea');
expect(sea.default()).toEqual({ control: [] });
expect(sea.default()).not.toBe(sea.default());
});
it('default() is not meaningful; a populated value is', () => {
expect(sea.isMeaningful(sea.default())).toBe(false);
expect(sea.isMeaningful({ control: [{ zone: 'pacific', by: 'a1' }] })).toBe(true);
});
it('validate() wraps the validator into the SeaOps shape', () => {
expect(sea.validate([{ zone: 'pacific', by: 'a1' }])).toEqual({
control: [{ zone: 'pacific', by: 'a1' }],
});
expect(sea.validate(undefined)).toEqual({ control: [] });
});
it('the stub value equals default() (the stub never proposes a sea op)', () => {
expect(sea.stub()).toEqual({ control: [] });
});
it('stateFields is [seaZones] (the durable GameState field)', () => {
expect(sea.stateFields).toEqual(['seaZones']);
});
it('payload() emits seaZones (even when empty / undefined)', () => {
expect(sea.payload({ seaZones: [] } as never)).toEqual({ seaZones: [] });
expect(sea.payload({} as never)).toEqual({ seaZones: [] });
expect(sea.payload({ seaZones: [{ zone: 'pacific', by: 'a1' }] } as never)).toEqual({
seaZones: [{ zone: 'pacific', by: 'a1' }],
});
});
it('apply() runs the ops then the per-turn bonus (no throw on a bad ref)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(80, 40, 20, 30), ['b-r1']),
},
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(80, 40, 20, 30) },
);
const ctx = { previous: state, actingTurn: state.turn };
sea.apply(
state,
{
control: [
{ zone: 'pacific', by: 'a1' },
{ zone: 'pacific', by: 'b2' },
// an unresolvable ref — must be dropped, not throw
{ zone: 'arctic', by: 'ghost' },
],
},
ctx,
);
// contest: b2 (military 80) beats a1 (military 50) → b2 controls pacific
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'b2' }]);
// b2 gets the +1/+1 bonus (it controls pacific)
expect(state.nations.b2.stats).toEqual(NS(81, 41, 20, 30));
// a1 is unaffected
expect(state.nations.a1.stats).toEqual(NS(50, 50, 50, 50));
});
it('apply() with an empty control still applies the per-turn bonus to existing claims', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ zone: 'pacific', by: 'a1' }],
);
const ctx = { previous: state, actingTurn: state.turn };
sea.apply(state, { control: [] }, ctx);
// no new claims, but the existing claim's controller earns the bonus
expect(state.seaZones).toEqual([{ zone: 'pacific', by: 'a1' }]);
expect(state.nations.a1.stats).toEqual(NS(51, 51, 50, 50));
});
});
+210
View File
@@ -0,0 +1,210 @@
// The SEA (naval / sea control) ability (v0.43.0, #65) as a modular
// LLM-ability (v0.40.0, #67) — the naval layer, fully parallel to but
// INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES
// (`SEA_ZONES` in shared/protocol.ts); each is under the SOLE CONTROL of at
// most one nation. Controlling a sea zone never moves land (`territory` /
// `unowned` are untouched) and moving land never touches a sea zone.
//
// Each turn the LLM declares `sea.control` ops — one op names the zone and
// the nation that (now) controls it. The engine resolves `by` (id first, then
// a case-insensitive unique name, via the shared `resolveNationRef`) and
// UPSERTS the zone's claim in `GameState.seaZones` (replace in place if the
// zone is already claimed, append if new). If two or more ops this turn claim
// the SAME zone for DIFFERENT resolved nations, the zone is CONTESTED: the
// higher current `stats.military` wins (a tie goes to the op listed earlier in
// the `control` array) — deterministic, no hidden randomness. An unresolvable
// `by` is dropped (never thrown).
//
// Every committed turn — INCLUDING a turn with an empty `control` — each
// existing claim's controller earns a small DETERMINISTIC bonus
// (`SEA_ZONE_BONUS = 1` military + 1 economy, clamped to 0–100, mirrored to
// the controller's regions and re-derived with the same 0-region / no-entry
// guards as the economy / migration layers); a claim whose nation no longer
// exists is skipped (the claim is kept, never thrown). The layer is DURABLE
// (unlike the turn-transient hybrid / disasters / migration layers):
// `GameState.seaZones` persists across turns and in SQLite, and the user
// payload always carries the current `seaZones` list (even when empty).
import {
clampStat,
deriveNationStats,
isSeaZoneId,
} from '../../shared/protocol';
export { SEA_ZONES, SEA_ZONE_IDS, isSeaZoneId, seaZoneName } from '../../shared/protocol';
import type { SeaControlOp, SeaOps } from '../../shared/protocol';
import type { GameState, Nation } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (v0.43.0, #65) The DETERMINISTIC per-turn bonus each sea-zone controller
* earns (applied to BOTH `military` and `economy`, clamped to 0–100, mirrored
* to the controller's regions, and re-derived with the same 0-region /
* no-entry guards as the economy / migration layers). No hidden randomness.
*/
export const SEA_ZONE_BONUS = 1;
/**
* (v0.43.0, #65) Validate the raw `sea` field of a turn into a safe
* {@link SeaOps}. Defensive like the rest of the parser — never throws. The
* raw value may be the object `{ control: [...] }` (the TurnResult shape) or
* a bare array (be lenient about either). Each op needs a valid `zone` (one of
* {@link SEA_ZONES}) and a non-empty `by` (the controlling nation's id or
* unique name, capped at 200 chars). An op with an unknown / missing `zone` or
* an empty / missing `by` is dropped. Resolution of `by` to a concrete nation
* id happens at apply time (`applySeaControl`), not here. A missing /
* malformed top-level value yields `{ control: [] }`.
*/
export function validateSeaOps(raw: unknown): SeaOps {
const out: SeaOps = { control: [] };
let list: unknown[] | null = null;
if (Array.isArray(raw)) {
list = raw;
} else if (raw && typeof raw === 'object') {
const inner = (raw as Record<string, unknown>).control;
if (Array.isArray(inner)) list = inner;
}
if (!list) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
const zone = str(o.zone, 100);
if (!zone || !isSeaZoneId(zone)) continue; // unknown / missing zone: drop
const by = str(o.by, 200);
if (!by) continue; // empty / missing controller: drop
out.control.push({ zone, by });
}
return out;
}
/**
* (v0.43.0, #65) Upsert the turn's sea-control ops into `next.seaZones`,
* mutating in place and never throwing. Each op's `by` is resolved against the
* CURRENT state (id first, then a case-insensitive unique name) — an
* unresolvable ref is dropped. Per zone (in first-appearance order): a single
* surviving op SETS or REPLACES the zone's controller (replace the existing
* entry in place — preserving its position — or append a new one); two or
* more surviving ops on the same zone for DIFFERENT resolved nations make the
* zone CONTESTED — the higher current `stats.military` wins (a tie goes to the
* op listed earlier in the `control` array), and the winner becomes the
* controller. A zone whose ops all resolve to the same nation is simply (re-)
* set to that nation. Sea control never touches `territory` / `unowned`.
*/
export function applySeaControl(next: GameState, ops: SeaControlOp[]): void {
// Group the ops by zone, keeping the original (first-appearance) order of
// zones and each zone's ops (so the contest tie-break "earlier in the
// control array" is well-defined).
const byZone = new Map<string, { index: number; by: string }[]>();
ops.forEach((op, index) => {
const list = byZone.get(op.zone) ?? [];
list.push({ index, by: op.by });
byZone.set(op.zone, list);
});
for (const [zone, list] of byZone) {
// Resolve each op's `by`; an unresolvable ref is dropped (the zone is
// still decided by the ops that do resolve).
const resolved = list
.map(({ index, by }) => ({ index, id: resolveNationRef(next, by) }))
.filter((r): r is { index: number; id: string } => r.id !== null);
if (resolved.length === 0) continue; // nothing resolved: zone untouched
// Contest: the highest current `stats.military` wins; a strict `>` keeps
// the EARLIER-listed op on a tie (the list is in control-array order).
let winner = resolved[0];
for (const r of resolved) {
const mw = next.nations[winner.id]?.stats.military ?? 0;
const mr = next.nations[r.id]?.stats.military ?? 0;
if (mr > mw) winner = r;
}
const existing = next.seaZones.find((c) => c.zone === zone);
if (existing) {
existing.by = winner.id; // replace in place (position preserved)
} else {
next.seaZones.push({ zone, by: winner.id });
}
}
}
/**
* (v0.43.0, #65) The per-turn sea bonus, applied EVERY committed turn (not
* just to this turn's claims): each entry of `next.seaZones` grants its
* controller `SEA_ZONE_BONUS` military + `SEA_ZONE_BONUS` economy (clamped
* to 0–100, mirrored to every region the nation owns, then the nation's
* derived `stats` re-derived — the same 0-region / no-entry guards as the
* economy / migration layers, so a 0-region nation keeps its directly-mutated
* value). A nation controlling several zones stacks the bonus; a claim whose
* nation no longer exists (removed by a merge / split this turn) is skipped —
* the claim is kept, never thrown. Never throws.
*/
export function applySeaZoneBonuses(
next: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.seaZones.length === 0) return;
// nation id -> {military, economy} delta (claims for the same nation stack).
const delta = new Map<string, { military: number; economy: number }>();
for (const c of next.seaZones) {
const d = delta.get(c.by) ?? { military: 0, economy: 0 };
d.military += SEA_ZONE_BONUS;
d.economy += SEA_ZONE_BONUS;
delta.set(c.by, d);
}
const applyNation = (n: Nation, d: { military: number; economy: number }): void => {
(['military', 'economy'] as const).forEach((k) => {
n.stats[k] = clampStat(n.stats[k] + d[k]);
});
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue; // a key with no entry is skipped
(['military', 'economy'] as const).forEach((k) => {
rs[k] = clampStat(rs[k] + d[k]);
});
}
// 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 (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
};
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue; // a nation removed this turn: the bonus is dropped (claim kept)
applyNation(n, d);
}
}
export const sea: Ability<'sea'> = {
field: 'sea',
default: () => ({ control: [] }),
validate: (raw) => validateSeaOps(raw),
apply: (state, data, ctx) => {
// (1) APPLY OPS: this turn's control ops upsert `GameState.seaZones`
// (a single op sets/replaces the zone's controller; a same-zone
// contest goes to the higher current `stats.military`, a tie to the
// earlier-listed op).
// (2) PER-TURN BONUS: EVERY committed turn — including this one, and
// including turns with an empty `control` — each existing claim's
// controller earns the deterministic military/economy bonus, so a
// zone that stays held keeps paying out.
applySeaControl(state, data.control);
applySeaZoneBonuses(state, ctx.geoOf);
},
schemaLine: () =>
' "sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},',
constraint: () =>
'- Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"<id>","by":"<nation>"}) sets or replaces that zone\'s controller (the user message\'s "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone\'s controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event).',
isMeaningful: (data) => data.control.length > 0,
stateFields: ['seaZones'],
stub: () => ({ control: [] }),
// Sea control is DURABLE (unlike the turn-transient hybrid / disasters /
// migration layers): the user payload ALWAYS carries the current `seaZones`
// list (even when empty) so the model knows who controls which zone.
payload: (ctx) => ({ seaZones: ctx.seaZones ?? [] }),
};
+10 -5
View File
@@ -12,16 +12,17 @@ import type {
IndustryProject,
RegionResources,
RelationStatus,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 8 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult`
* field each one owns. This is the closed universe the ordered `ABILITIES`
* registry (./index.ts) is built from: a turn's world changes are exactly the
* core engine effects (events / stats / territory / no-man's-land / nation-ops
* / markers) plus, in order, these eight abilities.
* / markers) plus, in order, these eleven abilities.
*/
export type AbilityField =
| 'takeovers'
@@ -31,7 +32,10 @@ export type AbilityField =
| 'industry'
| 'nuclear'
| 'leaders'
| 'ideology';
| 'ideology'
| 'disasters'
| 'migration'
| 'sea';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
@@ -64,7 +68,7 @@ export interface AbilityCtx {
readonly centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined;
/**
* The `getNeighbours` tool executor (verify land borders). Available to an
* ability that needs it; none of the current eight use it (it drives the
* ability that needs it; none of the current nine use it (it drives the
* core territory/conquest logic), but the type is here so a future one can.
*/
readonly getNeighbours?: (regionKeys: string[]) => string[];
@@ -87,6 +91,7 @@ export interface UserPromptCtx {
readonly embargoes?: Embargo[];
readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[];
readonly seaZones?: SeaZoneClaim[];
}
/**
@@ -151,7 +156,7 @@ export interface Ability<K extends AbilityField = AbilityField> {
* The `GameState` fields this ability owns and that must be defaulted on a
* legacy SQLite row (db.ts migration, only-if-undefined). `undefined` for
* abilities that add no durable state (takeovers, hybrid, nuclear, leaders,
* ideology).
* ideology, disasters).
*/
readonly stateFields?: readonly string[];
+13
View File
@@ -38,6 +38,8 @@ function makeState(id: string): GameState {
embargoes: [],
// (v0.36.0, #59) no industry projects.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
@@ -244,6 +246,17 @@ describe('openDb', () => {
db.close();
});
it('(v0.43.0 #65) a legacy row saved before the seaZones field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { seaZones?: unknown[] }).seaZones; // a pre-v0.43 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.seaZones).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+45 -56
View File
@@ -133,6 +133,8 @@ function baseState(): GameState {
embargoes: [],
// (v0.36.0, #59) no industry projects on a fresh state.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
@@ -570,7 +572,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -592,7 +594,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: [] }, 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: [] }, sea: { control: [] }, 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 +615,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -632,7 +634,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1089,7 +1091,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1106,7 +1108,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1129,7 +1131,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: [] }, 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: [] }, sea: { control: [] }, 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 +1157,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1173,7 +1175,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1263,7 +1265,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1378,7 +1380,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1685,9 +1687,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: [] }, 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: [] }, 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: [] }, 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: [] }, sea: { control: [] }, 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: [] }, sea: { control: [] }, 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: [] }, sea: { control: [] }, 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 +1702,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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1721,8 +1723,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1743,8 +1744,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1767,8 +1767,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1801,8 +1800,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1826,8 +1824,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1854,8 +1851,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1881,8 +1877,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1908,8 +1903,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1933,8 +1927,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1956,8 +1949,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1991,8 +1983,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2016,13 +2007,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: [] }, 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: [] }, sea: { control: [] }, 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: [] }, 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: [] }, sea: { control: [] }, 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
@@ -2049,8 +2040,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2097,8 +2087,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2123,7 +2112,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: [] }, 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: [] }, sea: { control: [] }, 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
@@ -2139,14 +2128,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: [] }, 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: [] }, sea: { control: [] }, 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: [] }, 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: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2216,8 +2205,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2292,6 +2280,8 @@ function mkTurnState(
embargoes: [],
// (v0.36.0, #59) no industry projects on a fresh state.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
}
@@ -2313,6 +2303,9 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
nuclear: partial.nuclear ?? { arm: [], strikes: [] },
leaders: partial.leaders ?? { changes: [] },
ideology: partial.ideology ?? { changes: [] },
disasters: partial.disasters ?? { events: [] },
migration: partial.migration ?? { ops: [] },
sea: partial.sea ?? { control: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3038,8 +3031,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3427,8 +3419,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3449,8 +3440,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3471,8 +3461,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] },
}),
).toBe(false);
});
+60 -36
View File
@@ -16,6 +16,7 @@ import type {
RegionResources,
RegionStats,
RoundItem,
SeaZoneClaim,
StatKey,
TradeAgreement,
} from '../shared/types';
@@ -57,8 +58,9 @@ import {
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
str,
} from './abilities/shared';
import { ABILITIES } from './abilities';
import { ABILITIES, type AbilityCtx, type UserPromptCtx } from './abilities';
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
// importing these shared primitives from the engine unchanged.
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
@@ -86,6 +88,31 @@ export {
validateTakeovers,
validateWarOps,
applyIndustryEffects,
validateDisasterEvents,
applyDisasterEvent,
isDisasterType,
isDisasterSeverity,
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,
validateSeaOps,
applySeaControl,
applySeaZoneBonuses,
SEA_ZONE_BONUS,
SEA_ZONES,
SEA_ZONE_IDS,
isSeaZoneId,
seaZoneName,
} from './abilities';
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -358,6 +385,14 @@ export function buildUserPrompt(
* key on any nation) when undefined.
*/
nuclear?: Record<string, boolean>,
/**
* (v0.43.0, #65) The game's current sea-zone control (each a `{ zone, by }`
* pair of the fixed sea zones and their controlling nation id). Emitted
* verbatim (even when empty) so the model knows which of the 12 fixed sea
* zones each nation currently controls and can re-claim or contest them via
* `TurnResult.sea`. Omitted entirely when undefined.
*/
seaZones?: SeaZoneClaim[],
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
@@ -458,7 +493,7 @@ export function buildUserPrompt(
// (v0.40.0, #67) Build the UserPromptCtx once and merge each ability's
// payload() at its exact position (the key order is load-bearing for the
// golden test).
const upCtx = {
const upCtx: UserPromptCtx = {
state,
markers,
wars,
@@ -467,15 +502,16 @@ export function buildUserPrompt(
embargoes,
nationResources,
industryProjects,
seaZones,
};
const upCtxAny = upCtx as unknown as import('./abilities').UserPromptCtx;
Object.assign(payload, ABILITIES[0].payload(upCtxAny)); // takeovers → takeoverRisks
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
Object.assign(payload, ABILITIES[1].payload(upCtxAny)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtxAny)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtxAny)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtxAny)); // industry → resources, industry
Object.assign(payload, ABILITIES[1].payload(upCtx)); // wars → wars
Object.assign(payload, ABILITIES[2].payload(upCtx)); // hybrid → relations
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted)
return JSON.stringify(payload, null, 2);
}
@@ -534,13 +570,6 @@ export function parseTurnResult(
return validateTurnResult(obj, regionKeyResolver);
}
function str(s: unknown, max = 0): string | null {
if (typeof s !== 'string') return null;
const t = s.trim();
if (!t) return null;
return max ? t.slice(0, max) : t;
}
/**
* Validate a raw (LLM) `nationOps` array into safe {@link NationOp} values
* (v0.23, #16 P4a). Defensive like the rest of the parser: entries with an
@@ -621,6 +650,9 @@ export function validateTurnResult(
nuclear: { arm: [], strikes: [] },
leaders: { changes: [] },
ideology: { changes: [] },
disasters: { events: [] },
migration: { ops: [] },
sea: { control: [] },
markers: { add: [], remove: [] },
};
@@ -711,7 +743,7 @@ export function validateTurnResult(
// list for the player to confirm/reject.
result.nationOps = validateNationOps(o.nationOps);
// The 8 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// The 11 per-feature LLM abilities (v0.40.0, #67): each one's validate call
// is owned by its Ability module in the ABILITIES registry. The field's raw
// value is passed through a.validate() which returns the validated field shape.
for (const a of ABILITIES) {
@@ -824,19 +856,6 @@ export function validateRegionChanges(
return out;
}
/**
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
* {@link Takeover} list — the non-military regime-change lever (a coup or
* revolution that renames a nation to its new regime name). Defensive like
* the rest of the parser — never throws. Each entry needs a non-empty
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
* to a concrete nation id happens at apply time (`resolveNationRef`), not
* here. Entries with either field missing or whitespace-only are dropped.
*/
// (v0.40.0, #67) The 8 per-feature ability validators, appliers, and helpers
// have been moved to server/abilities/ — re-exported below for the tests.
export function isMeaningful(result: TurnResult): boolean {
return (
result.events.length > 0 ||
@@ -845,7 +864,7 @@ export function isMeaningful(result: TurnResult): boolean {
result.regionChanges.length > 0 ||
result.unowned.length > 0 ||
result.nationOps.length > 0 ||
// (v0.40.0, #67) any of the 8 per-feature abilities having a non-empty
// (v0.40.0, #67) any of the 9 per-feature abilities having a non-empty
// payload is a real world change (each Ability's isMeaningful predicate
// is the per-ability half of the old 18-sub-field check).
ABILITIES.some((a) => a.isMeaningful(result[a.field])) ||
@@ -918,6 +937,9 @@ export function applyTurnResult(
// (v0.36, #59) active industry projects; a legacy/corrupt clone without the
// field starts empty (the turn then only ever adds to it).
next.industry = next.industry ?? [];
// (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field
// starts empty (the turn then upserts this turn's claims into it).
next.seaZones = next.seaZones ?? [];
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -1112,12 +1134,12 @@ export function applyTurnResult(
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
// (v0.40.0, #67) The 8 per-feature LLM abilities, applied in ABILITIES
// (v0.40.0, #67) The 9 per-feature LLM abilities, applied in ABILITIES
// order (takeovers → wars → hybrid → economy → industry → nuclear →
// leaders → ideology). Each Ability's apply method handles its own
// field's validated data against the post-territory/post-recompute
// leaders → ideology → disasters). Each Ability's apply method handles its
// own field's validated data against the post-territory/post-recompute
// `next` state. The AbilityCtx carries the shared resolvers.
const abilityCtx = {
const abilityCtx: AbilityCtx = {
previous: state,
actingTurn,
validRegionKeys: validKeys,
@@ -1126,9 +1148,8 @@ export function applyTurnResult(
resourceOf,
centroidOf,
};
const abilityCtxAny = abilityCtx as unknown as import('./abilities').AbilityCtx;
for (const a of ABILITIES) {
a.apply(next, result[a.field], abilityCtxAny);
a.apply(next, result[a.field], abilityCtx);
}
// --- Map markers (v0.27, #44) ---
@@ -1596,6 +1617,9 @@ export async function runTurn(opts: {
state.industry ?? [],
// (v0.37.0, #60) per-nation nuclear capability (flag or rocket marker).
nuclearCapability,
// (v0.43.0, #65) the current sea-zone control (always emitted — even when
// empty — so the model knows who controls which of the fixed sea zones).
state.seaZones ?? [],
);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
+2
View File
@@ -190,6 +190,8 @@ app.post('/api/games', (req: Request, res) => {
// declares them over time via TurnResult.industry (one per region, a
// per-region SET).
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
+2
View File
@@ -59,6 +59,8 @@ const state: GameState = {
embargoes: [],
// (v0.36.0, #59) the map is ownership-only; industry is unused here.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+2
View File
@@ -48,6 +48,8 @@ function mkState(
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
+2
View File
@@ -118,6 +118,8 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
embargoes: [],
// (v0.36.0, #59) unused here (allTerritoryKeys is ownership-only).
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
+22 -2
View File
@@ -100,6 +100,14 @@ export interface StubPayload {
* deterministicTurn), so the industry path is covered by unit tests only.
*/
industry?: Array<{ regionKey: string; type: string }>;
/**
* (v0.43.0, #65) The currently active sea-zone control
* (`{ zone, by }` — fixed sea-zone id + controlling nation id), emitted by
* the engine's user prompt (always, even when empty). The stub ignores this
* field — it never claims, contests or re-claims a sea zone (see
* deterministicTurn), so the sea path is covered by unit tests only.
*/
seaZones?: Array<{ zone: string; by: string }>;
}
function truncate(s: string, n: number): string {
@@ -173,6 +181,18 @@ function truncate(s: string, n: number): string {
* (v0.39.0, #62) The stub likewise NEVER changes a nation's ideology (it
* always returns `ideology: { changes: [] }`) — the ideology path is covered
* by unit tests only, keeping the e2e assertions untouched.
*
* (v0.41.0, #63) The stub likewise NEVER declares a disaster or pandemic (it
* always returns `disasters: { events: [] }`) — the disasters path is covered
* by unit tests only, keeping the e2e assertions untouched.
*
* (v0.42.0, #64) The stub likewise NEVER declares a migration / refugee flow
* (it always returns `migration: { ops: [] }`) — the migration path is
* covered by unit tests only, keeping the e2e assertions untouched.
*
* (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea
* zone (it always returns `sea: { control: [] }`) — the sea-control path is
* covered by unit tests only, keeping the e2e assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -252,13 +272,13 @@ export function deterministicTurn(p: StubPayload): TurnResult {
nationOps: [], // the stub never proposes nation ops (see doc comment)
};
// The 8 ability fields, filled by the registry in its canonical order. The
// The 11 ability fields, filled by the registry in its canonical order. The
// stub is ability-inert: every ability contributes its own no-op default
// (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology), which must stay byte-identical.
// ideology → disasters → migration → sea), which must stay byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();
+243
View File
@@ -598,6 +598,191 @@ export function isNuclearCapable(nation: Nation, markers: readonly MapMarker[]):
return markers.some((m) => m.ownerNationId === nation.id && m.type === 'rocket');
}
// --- Disasters & pandemics (v0.41.0, #63) ------------------------------------
//
// Natural and man-made catastrophes — earthquakes, floods, volcanic eruptions,
// wildfires, famines, plagues, and epidemics — hit a nation or a single region
// each turn. Each event carries a `type` and a `severity`; the engine applies
// a DETERMINISTIC stat dip chosen from the `(type, severity)` lookup table
// `DISASTER_EFFECTS` (defined in `server/abilities/disasters.ts`) — no hidden
// randomness: the LLM chooses the type and severity, the table decides the
// magnitude. The dip is clamped to 0–100, mirrored to the target's regions
// (`regionStats`), and the target's derived `stats` re-derived afterwards
// (the same 0-region / no-entry guards as the hybrid / economy layers). It is
// **turn-transient** like the hybrid layer: there is NO `GameState` field, no
// durable list, and no `db.ts` change — an ongoing plague / epidemic is simply
// re-declared each turn it persists. Disasters never move territory (sole
// ownership is untouched) and never touch `population`.
/** The kind of catastrophe a disaster event describes (v0.41.0, #63). */
export type DisasterType =
| 'earthquake'
| 'flood'
| 'volcanic'
| 'wildfire'
| 'famine'
| 'plague'
| 'epidemic';
/** The severity a disaster event carries (v0.41.0, #63). */
export type DisasterSeverity = 'minor' | 'moderate' | 'severe';
/**
* A single disaster event (v0.41.0, #63): the catastrophe `type`, its
* `target` (a nation id / name, or a region key that hits that region's sole
* owner — resolved at apply time), the `severity`, and an optional short
* `detail`. The deterministic stat dip is looked up from
* `DISASTER_EFFECTS[type][severity]`.
*/
export interface DisasterEvent {
/** The kind of catastrophe (one of {@link DisasterType}). */
type: DisasterType;
/** The affected nation (by id or name) or a region key (hits its sole owner). */
target: string;
/** How bad it was (one of {@link DisasterSeverity}). */
severity: DisasterSeverity;
/** Optional short annotation (≤300 chars). */
detail?: string;
}
/** The disasters block of a `TurnResult`. Required; default `{ events: [] }`. */
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[];
}
// ---------------------------------------------------------------------------
// (v0.43.0, #65) Sea zones & sea control — the naval layer, fully parallel to
// but INDEPENDENT of land. The world has a fixed set of 12 named SEA ZONES
// ({@link SEA_ZONES}); each is under the SOLE CONTROL of at most one nation.
// Controlling a sea zone never moves land (`territory` / `unowned` are
// untouched) and moving land never touches a sea zone. `SeaZoneClaim` (the
// durable per-zone record, `{ zone, by }`) lives in `shared/types.ts` like
// `Trade` / `Embargo` / `War` / `IndustryProject`; `GameState.seaZones` is a
// list of them, unique per zone (`[]` at creation; a legacy SQLite row
// without the field loads as `[]` — additive, never wiped). Each turn the LLM
// declares `TurnResult.sea.control` ops — one op names the zone and the
// nation that (now) controls it. The engine resolves `by` (id first, then a
// case-insensitive unique name) and UPSERTS the zone's claim (replace in
// place if the zone is already claimed, append if new). If two or more ops
// this turn claim the SAME zone for DIFFERENT resolved nations, the zone is
// CONTESTED: the higher current `stats.military` wins (a tie goes to the op
// listed earlier in the `control` array) — deterministic, no hidden
// randomness; an unresolvable `by` is dropped (never thrown). Every committed
// turn — including a turn with an empty `control` — each existing claim's
// controller earns a small DETERMINISTIC bonus (`SEA_ZONE_BONUS = 1` military
// + 1 economy, clamped to 0–100, mirrored to the controller's regions and
// re-derived with the same 0-region / no-entry guards as the economy /
// migration layers); a claim whose nation no longer exists is skipped (the
// claim is kept, never thrown). The layer is DURABLE (unlike the turn-
// transient hybrid / disasters / migration layers): `GameState.seaZones`
// persists across turns and in SQLite. The user payload carries the current
// `seaZones` list so the model knows who controls which zone.
/** The 12 named sea zones the world model knows (v0.43.0, #65). */
export const SEA_ZONES = [
{ id: 'north-atlantic', name: 'North Atlantic' },
{ id: 'south-atlantic', name: 'South Atlantic' },
{ id: 'pacific', name: 'Pacific Ocean' },
{ id: 'indian', name: 'Indian Ocean' },
{ id: 'mediterranean', name: 'Mediterranean Sea' },
{ id: 'north-sea', name: 'North Sea' },
{ id: 'baltic', name: 'Baltic Sea' },
{ id: 'arctic', name: 'Arctic Ocean' },
{ id: 'black-sea', name: 'Black Sea' },
{ id: 'gulf-mexico', name: 'Gulf of Mexico' },
{ id: 'red-sea', name: 'Red Sea' },
{ id: 'sargasso', name: 'Sargasso Sea' },
] as const;
/** The set of valid sea-zone ids (v0.43.0, #65). */
export const SEA_ZONE_IDS: ReadonlySet<string> = new Set(SEA_ZONES.map((z) => z.id));
/** Whether `id` is one of the fixed sea-zone ids (v0.43.0, #65). */
export function isSeaZoneId(id: string): boolean {
return SEA_ZONE_IDS.has(id);
}
/** The display name of a sea-zone id; an unknown id is returned as-is (v0.43.0, #65). */
export function seaZoneName(id: string): string {
return SEA_ZONES.find((z) => z.id === id)?.name ?? id;
}
/**
* A single sea-control op (v0.43.0, #65): `zone` is one of the fixed
* {@link SEA_ZONES} ids and `by` is the nation that (now) controls it (by id
* or name). One op SETS or REPLACES the zone's controller; two or more ops on
* the same zone for different nations are CONTESTED — the higher current
* `stats.military` wins (a tie: the earlier-listed op).
*/
export interface SeaControlOp {
/** The sea zone (one of {@link SEA_ZONE_IDS}). */
zone: string;
/** The controlling nation (by id or name). */
by: string;
}
/** The sea-control block of a `TurnResult`. Required; default `{ control: [] }`. */
export interface SeaOps {
control: SeaControlOp[];
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -749,6 +934,64 @@ export interface TurnResult {
* Required; default `{ changes: [] }`.
*/
ideology: IdeologyOps;
/**
* (v0.41.0, #63) Disasters & pandemics that struck this turn — earthquakes,
* floods, volcanic eruptions, wildfires, famines, plagues, and epidemics,
* each hitting a **nation** (by id or name) or a single **region** (by key,
* which hits that region's SOLE owner — a key with 0 or 2+ owners can't be
* hit). Each event names a `type` (one of the seven `DisasterType`s) and a
* `severity` (minor / moderate / severe); the engine applies a small,
* DETERMINISTIC stat dip from the `(type, severity)` table
* (`DISASTER_EFFECTS`, in `server/abilities/disasters.ts`) — clamped to
* 0–100, mirrored to the target's regions, and the target's derived `stats`
* re-derived afterwards (no hidden randomness). They NEVER move borders
* (sole ownership is untouched) and never touch `population`, and the layer
* is turn-transient (no durable state): an ongoing plague / epidemic is
* simply re-declared each turn it persists. At most 1–2 events per turn,
* each narrated in `events` (a `disaster` event). Required; default
* `{ 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.43.0, #65) Sea-zone control ops this turn — the naval layer, fully
* parallel to but INDEPENDENT of land: each op names one of the 12 fixed
* sea zones (`SEA_ZONES`) and the nation that now controls it. The engine
* resolves `by` (id first, then a case-insensitive unique name) and
* upserts the zone's claim in `GameState.seaZones` (a single op sets or
* replaces the controller; two or more ops on the same zone for different
* nations are CONTESTED — the higher current `stats.military` wins, a tie
* goes to the op listed earlier in `control`; an unresolvable `by` is
* dropped, never thrown). Every committed turn, each existing claim's
* controller earns a small deterministic military/economy bonus
* (`SEA_ZONE_BONUS`). Sea control never moves land: `territory` and
* `unowned` are untouched. At most 1–2 per turn, each narrated in `events`
* (a `military` or `diplomatic` event). Required; default
* `{ control: [] }`.
*/
sea: SeaOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
+2
View File
@@ -461,6 +461,8 @@ export function worldDraftToGameState(
embargoes: [],
// (v0.36, #59) No industry projects on a fresh (dynamic) game.
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
};
return state;
}
+31
View File
@@ -249,6 +249,23 @@ export interface IndustryProject {
type: IndustryType;
}
/**
* (v0.43.0, #65) One sea-zone claim: which nation controls one of the fixed
* sea zones (see `SEA_ZONES` in `shared/protocol.ts`). The world keeps at
* most ONE claim per zone — a new op on an already-claimed zone REPLACES the
* controller in place (the zone's entry is updated, never duplicated), and if
* a turn declares the same zone for two different resolved nations the higher
* current `stats.military` wins (a tie: the op listed earlier in the turn's
* `control` array). Sea control is fully parallel to but INDEPENDENT of land:
* it never touches `territory` / `unowned`.
*/
export interface SeaZoneClaim {
/** The sea zone (one of `SEA_ZONE_IDS`). */
zone: string;
/** The id of the nation that controls it (resolved, not a name). */
by: string;
}
/**
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
* between two or more nations. Unlike the boolean `war` relation (which a
@@ -443,6 +460,20 @@ export interface GameState {
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
*/
industry: IndustryProject[];
/**
* (v0.43.0, #65) Sea-zone control (see {@link SeaZoneClaim}): one entry per
* controlled sea zone — which of the 12 fixed zones (`SEA_ZONES`) each
* nation controls. At most ONE entry per zone (unique by `zone`); a turn's
* `sea.control` ops upsert entries (a re-claim by a different nation
* replaces the controller in place; a same-zone contest goes to the higher
* current `stats.military`, a tie to the earlier-listed op). Every
* committed turn each claim's controller earns a small deterministic
* military/economy bonus (`SEA_ZONE_BONUS`). Sea control never moves land
* (`territory` / `unowned` are untouched). Always `[]` on a fresh game;
* rows saved before v0.43 load with this defaulted to `[]` (see
* server/db.ts) — additive, no data wipe.
*/
seaZones: SeaZoneClaim[];
}
/**