Compare commits

...
Author SHA1 Message Date
kamilkrzywda 0a6c5aef9c Merge pull request 'feat: #66 contested regions / frontlines (v0.44.0)' (#79) from feat/66-contested into master
CI / build + unit + e2e (push) Successful in 2m16s
Merge #79: feat: #66 contested regions / frontlines (v0.44.0)
2026-10-16 17:26:34 +02:00
kamilkrzywda 5477336537 feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
CI / build + unit + e2e (pull_request) Successful in 2m10s
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
2026-10-16 15:21:45 +00:00
kamilkrzywda d946fdf0d7 Merge pull request 'feat: #65 sea control (v0.43.0)' (#78) from feat/65-naval-sea into master
CI / build + unit + e2e (push) Successful in 2m32s
2026-10-16 14:53:48 +02:00
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
kamilkrzywda df52261835 Merge pull request 'v0.40.0 — Modular LLM-ability system (ordered registry)' (#75) from refactor/ability-modules into master
CI / build + unit + e2e (push) Successful in 2m17s
2026-10-16 09:27:43 +02:00
kamilkrzywda a46efcc1cb refactor(#74) P6: bump to v0.40.0 + changelog
CI / build + unit + e2e (pull_request) Successful in 2m44s
- package.json: 0.39.0 → 0.40.0.
- CHANGELOG.md: new v0.40.0 entry documenting the modular LLM-ability
  system (the behavior-preserving extraction of the 8 per-feature
  abilities into the ordered ABILITIES registry), with the guarantees
  (byte-identical prompts/mutations/stub, zero client changes, no new
  deps, shared/ stays dep-free) and the new registry/golden tests.

Verified: typecheck + lint + 522 unit tests + build all green.

Fixes #74
2026-10-16 07:23:11 +00:00
kamilkrzywda 4f8e3534ff refactor(#74) P5: add registry contract + golden-prompt tests
Adds server/abilities/registry.test.ts, the safety net for the refactor:

- GOLDEN PROMPTS: the refactored engine's system prompt (both tool
  variants) and user prompt are asserted BYTE-IDENTICAL to the committed
  fixtures in server/abilities/goldens/ (captured in P1 from the
  pre-refactor engine). The state builder the goldens were captured from
  is re-embedded here, with a REGEN_GOLDENS=1 mode to rewrite the
  fixtures after an intentional prompt change (verified a no-op — the
  refactored output is exactly stable).
- REGISTRY CONTRACT: ABILITIES is the canonical 8-ability order with 8
  distinct TurnResult fields; each ability's default() equals the
  canonical TurnResult default and is a FRESH value (never a shared
  reference); the default is never "meaningful" while a populated value
  is; the registry declares exactly the durable GameState fields the db
  migration must default (wars/trades/embargoes/industry); the registry
  composes (a hypothetical extra ability appends cleanly to every
  uniform loop); and the engine still re-exports every name its callers
  import from it (backward compatibility).

The throwaway _golden-capture.ts is deleted (its logic is now the
REGEN mode of this test).

Fixes #74
2026-10-16 07:23:11 +00:00
kamilkrzywda 15a1642eec refactor(#74) P4: wire engine/stub/db through the ABILITIES registry
Behavior-preserving rewire: every per-ability code path now iterates the
ordered ABILITIES registry instead of hand-enumerating the eight fields.

- engine.ts: buildSystemPrompt (schema lines + constraint bullets),
  buildUserPrompt (payload keys at their historical positions),
  validateTurnResult (per-field validate), applyTurnResult (ordered apply
  block), isMeaningful, and the runTurn "unclear" fallback all drive off
  ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted;
  the names the engine and its tests import are re-exported from
  ./abilities.
- abilities/types.ts: Ability.stub is a zero-arg method (the stub has no
  GameState/AbilityCtx to pass; it needs none) — all eight implementations
  were already arg-less.
- stub.ts: deterministicTurn fills the eight ability fields from the
  registry (each contributes its inert default), between the core fields
  and markers — JSON output stays byte-identical (verified against
  origin/master).
- db.ts: the legacy-row migration for ability-owned GameState fields
  (wars/trades/embargoes/industry) iterates the registry's stateFields,
  only-if-undefined, instead of a hardcoded field list.

No client changes, no new deps, no shared/ runtime deps. typecheck + lint
+ 511 unit tests green; stub JSON byte-identical to origin/master.

Fixes #74
2026-10-16 07:23:11 +00:00
kamilkrzywda 2d17c743f6 refactor(#74) P3: add the 8 ability modules + ABILITIES registry
Each per-feature LLM ability is now a self-contained module in
server/abilities/ with its own Ability<K> const (schema line, constraint
bullet, validator, applier, isMeaningful, stub default, user-payload
field). The ordered ABILITIES registry (index.ts) assembles them in the
canonical order: takeovers → wars → hybrid → economy → industry → nuclear
→ leaders → ideology.

All function bodies are moved VERBATIM from engine.ts. The engine is
untouched in this commit — P4 will rewire it to use the registry.
2026-10-16 07:23:11 +00:00
kamilkrzywda 5cfbce861b refactor(#74) P2: extract shared helpers to server/abilities + define the Ability type surface
Move the 5 shared reference/id primitives (resolveNationRef, resolveRegionKey,
freshNationId, freshWarId, NEW_NATION_STATS) from engine.ts into the new
server/abilities/shared.ts (breaking the engine<->ability import cycle), and
add server/abilities/types.ts with the Ability<K> interface + AbilityCtx /
UserPromptCtx types that the 8 per-feature ability modules will implement.
engine.ts imports the helpers and re-exports them so existing callers
(server/index.ts, the unit tests) are untouched. Behavior is identical; all
511 unit tests, lint and typecheck stay green.
2026-10-16 07:23:11 +00:00
kamilkrzywda fc908aaea9 refactor(#74) P1: capture golden prompt baselines for the ability-module refactor
Adds deterministic golden fixtures (server/abilities/goldens/) pinning the
EXACT output of buildSystemPrompt (no-tool + tool modes) and buildUserPrompt
for a fixed wwii-1939 preset plus a crafted state that exercises every
optional payload branch (regions, frontier, frontierMilitary, takeoverRisks,
unowned, markers, wars, relations, trades, embargoes, resources, industry).

These are the target: the v0.40.0 modular ability registry (P2-P5) must
reproduce these byte-for-byte. Part of #74.
2026-10-16 07:23:11 +00:00
kamilkrzywda fff4163f2c Merge pull request 'feat(#62): ideology / government-type change (v0.39.0)' (#73) from feat/62-ideology into master
CI / build + unit + e2e (push) Successful in 2m6s
2026-10-15 22:57:36 +02:00
kamilkrzywda 7866a1987d feat(#62): ideology / government-type change (v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 2m33s
Add a per-nation free-form ideology (form of government) field and a
TurnResult.ideology lever (coups / revolutions / reforms / constitutional
changes) with a deterministic -5 stability dip and political narration.

- shared/types.ts: Nation.ideology?: string (optional/additive)
- shared/protocol.ts: IdeologyChange / IdeologyOps, IDEOLOGY_STABILITY_EFFECT
  = -5, TurnResult.ideology (required, between leaders and markers),
  nationForPrompt now carries ideology
- server/engine.ts: validateIdeologyOps (exported, never throws),
  applyIdeologyChange (resolve -> set field -> -5 dip mirrored to regions +
  re-derived -> political event), ideology block in applyTurnResult after
  leaders / before markers, isMeaningful + validateTurnResult + system prompt
  + runTurn unclear fallback
- server/stub.ts: StubPayload gains ideology; deterministicTurn returns
  ideology: { changes: [] } (never changes one, e2e unaffected)
- Tests: engine.test.ts (validate / isMeaningful / applyTurnResult /
  buildUserPrompt), protocol.test.ts (constant + nationForPrompt),
  db.test.ts (legacy load + round-trip); every TurnResult fixture updated
- package.json -> 0.39.0; CHANGELOG v0.39.0 entry
2026-10-15 20:54:18 +00:00
kamilkrzywda 9d201135b3 Merge pull request 'feat(#61): named leaders — assassination / death / appointment with succession (v0.38.0)' (#72) from feat/61-named-leaders into master
CI / build + unit + e2e (push) Successful in 2m10s
2026-10-15 22:14:24 +02:00
41 changed files with 8040 additions and 1657 deletions
+187
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 12-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,183 @@ 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.
- **Contested regions / frontlines — a `TurnResult.contested` lever opening/resolving war frontlines: a THIRD region state in no nation's `territory` and not in `unowned` (v0.44.0, #66).**
`TurnResult.contested = ContestedOps = { open: ContestOp[]; resolve: string[] }` (required,
default `{ open: [], resolve: [] }`); `ContestOp = { regionKey, parties }` where `regionKey` is
a region key or resolvable name and `parties` are TWO OR MORE nation refs (ids or names, via
the shared `resolveNationRef`). Like the `sea` / `industry` layers this is **durable**: it owns
the `GameState.contested: ContestedRegion[]` field (`ContestedRegion = { regionKey, parties }`
— `parties` store the RESOLVED nation ids; 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). `applyContested` (run LAST in the `ABILITIES` loop, after
territory / wars / economy / sea — it sees the final post-claim ownership) runs **RESOLVE
first, then OPEN**: a `resolve` removes a currently-contested key and **releases it to no
man's land** (into `unowned`, out of any territory, `regionStats` deleted) — UNLESS a
concurrent winning claim by one of the contesting **parties** already re-owned it this turn
(the only claim the territory step permits on a contested key), in which case it stays owned
and keeps (or re-inits) its `regionStats`; an `open` makes a region a frontline only when it
is **contestable** — a **sole owner** at war (a shared **active** `War`) with at least one
party, or a **no-man's-land** region (0 owners AND in `unowned`) whose two parties are at war
with each other — a 2+-owner key or an owner not at war with any party is **dropped**
(an armistice/ended war does not count); on a successful open the key is **stripped from its
owner's `territory` (or from `unowned`)**, its `regionStats` entry deleted, and a
`{ regionKey, parties }` entry added to `next.contested` (**unique by `regionKey`** — a
re-open replaces a kept entry), with the stripped owner's derived stats re-derived (same
0-region / no-entry guards). The **sacrosanct sole-ownership invariant is never broken**: a
contested key is in **no** territory (never two) and is **never** in `unowned` while
contested (the two states are mutually exclusive). The **territory step in `engine.ts` is
guarded**: a normal `gain` on a currently-contested key is **dropped** unless this same turn
resolves the contest AND the gainer is one of the contesting parties (a party re-claim) — a
still-contested key is never a valid normal-claim target. The **map (client-only, like
no-man's-land)**: `WorldMap` renders a contested key in the distinct visible
`CONTESTED_FILL` / `CONTESTED_STROKE` (orange "hot" frontline) with a `"Contested: A vs B"`
hover sub-line (names from `GameState.contested`; a removed party degrades to a dash) — the
contested check runs **first** (before unowned / owned); the server `RegionMap` stays
owned-vs-absent (it never knows about contested). **The map legend stays at 7 items** (it is
derived from the `RegionMap` owners only — a contested key is absent from the `RegionMap`, so
no new legend entry; e2e legend assertions are unaffected). `isMeaningful` is
`open.length > 0 || resolve.length > 0`; the system prompt documents the field + constraints
(at most 1–2 contested-region changes per turn, each narrated in `events`); the user payload
carries the current frontlines (region key + party **names**) **only when non-empty** —
`payload()` → `{}` on a fresh / no-frontline world (unlike `sea`, which always emits). The
**stub never** opens or resolves a frontline (`{ open: [], resolve: [] }`), so e2e (CI,
`LLM_STUB=1`) is unaffected and the path is unit-tested only
(`server/abilities/contested.test.ts`, 52 tests, each asserting the sole-ownership invariants).
**Design note (deviation from the issue body):** the Gitea #66 body sketched a **two-owner**
`disputed` / `fronts` design that "relaxes sole-ownership"; the implemented, approved design
is this **third region state** (a contested key in NO territory, sole-ownership preserved)
with `open` / `resolve` ops and a minimum of two contesting parties.
- **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
+85
View File
@@ -2,6 +2,91 @@
All notable changes to **History** are listed here, newest first.
## v0.44.0 — 2026-10-16 15:10
- **#66: Contested regions / frontlines — a `TurnResult.contested` lever letting the LLM declare a region a **contested** war frontline (a **third region state**: in **no** nation's `territory` and **not** in `unowned` / no-man's-land) and resolve existing frontlines.** It lands as the **12th ability** of the v0.40.0 modular system, in a new `server/abilities/contested.ts` module, **registered last** in the `ABILITIES` registry (after `sea`). Like the `sea` and `industry` layers it is **durable**: `GameState.contested` persists across turns and in SQLite. It is a **pure territory-state** layer — the contested key simply *leaves* ownership (sole ownership is never broken: a key is in at most one `territory`), it is never placed into two territories, and it never touches borders directly.
- **Types (`shared/protocol.ts`).** New `ContestOp` (`{ regionKey; parties }` — `regionKey` is a region key or resolvable name, `parties` are two or more nation refs (ids or names)), `ContestedOps` (`{ open: ContestOp[]; resolve: string[] }`), and `ContestedRegion` (`{ regionKey; parties }` — `parties` store the **resolved** nation ids); `TurnResult.contested` is **required**, placed between `sea` and `markers`, default `{ open: [], resolve: [] }`. New `CONTESTED_FILL = '#f97316'` / `CONTESTED_STROKE` (the distinct visible frontline fill/stroke for the map — a reddish/orange "hot" frontline on the dark basemap, clearly distinct from a nation color and from the no-man's-land grey) and `contestedTooltipLabel(parties)` (a short `"Contested: A vs B"` hover sub-line; a missing party name degrades to a dash, never crashes). `ContestedRegion` is imported into `shared/types.ts`; `GameState.contested: ContestedRegion[]` 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/contested.ts`).** `field: 'contested'`; `default()` / `stub()` → a **fresh** `{ open: [], resolve: [] }` each call; `validate` wraps the exported `validateContestedOps` (defensive, never throws — accepts the `{ open, resolve }` object; an `open` op needs a non-empty `regionKey` (trimmed, ≤120) and **two or more** non-empty `party` refs (each trimmed, ≤200), a `resolve` entry a non-empty key (trimmed, ≤120); missing / malformed → dropped; a missing / malformed top-level object yields `{ open: [], resolve: [] }`); `apply` runs the exported `applyContested`; `schemaLine()` / `constraint()` supply the prompt text; **`payload()` → `{ contested: [{ regionKey, parties: [NAMES] }] }`** (the current frontlines with party **names** resolved from the stored ids) **only when non-empty** — `{}` on a fresh / no-frontline world (like the turn-transient layers, unlike `sea` which always emits); `isMeaningful` = `open.length > 0 || resolve.length > 0`; **`stateFields: ['contested']`** (the durable `GameState` field, for the `db.ts` legacy-row migration).
- **Open semantics (`applyContested`, OPEN phase).** Each `open` op's `regionKey` resolves to a concrete key (exact key first, then a unique case-insensitive name via the region manifest — with no manifest loaded the raw string is accepted; a key absent from the loaded manifest is dropped). Its `parties` resolve via the shared `resolveNationRef` (id first, then a unique name; unresolvable / ambiguous refs dropped, order preserved, duplicates removed) — an op left with **fewer than two distinct resolved parties** is dropped. A region is **contestable** only when either (a) it has a **sole owner** that is at war (a shared **active** `War`) with at least one of the parties, or (b) it has **zero owners AND is in `unowned`** (no man's land) and **two of the parties are at war with each other**. A region with **two or more owners** (corrupt), or whose owner is **not at war with any party**, is never contestable (dropped — an ended / armistice war does not count). On a successful open the key is **removed from its sole owner's `territory`** (or from `unowned` for a no-man's-land region), its `regionStats` entry is **deleted**, and a `{ regionKey, parties: [ids] }` entry is added to `next.contested` (**unique by `regionKey`** — a re-open replaces any kept entry for the same key). The stripped owner's derived `stats` are **re-derived** (the same 0-region / no-entry guards as the other layers, so a 0-region owner keeps its directly-mutated value).
- **Resolve semantics (`applyContested`, RESOLVE phase — runs FIRST).** Each key in `resolve` that is currently in `next.contested` is removed and its region **released to no man's land** (out of any territory, into `unowned`, `regionStats` deleted) — **UNLESS a concurrent winning claim by one of the contesting parties** already re-owned it this turn (the only claim the territory step permits on a contested key), in which case it **stays owned** and its `regionStats` is **kept** (or **re-initialised** as a copy of the owner's stats if absent). A key that is not currently contested is ignored (no-op). A resolved key owned by a **non-party** (defensive) is released as above.
- **Territory-step guard (`engine.ts applyTurnResult`).** A **currently-contested** key (in `state.contested`) sits in no nation's territory, so a normal `gain` would otherwise be treated as a free re-claim. The claims step is guarded: a normal claim on a contested key proceeds **only** when this same turn `resolve`s the contest **AND** the gainer is one of the contesting **parties** (a party re-claim); otherwise the frontline **stays contested** for the contested layer (which runs later in the turn) to decide its fate. A still-contested key is therefore never a valid normal-claim target.
- **Map (client `WorldMap.tsx`).** The contested state is decided by the client from `GameState.contested` (like no-man's-land), **never by the `RegionMap`** (which stays owned-vs-absent). A contested key is rendered with the distinct `CONTESTED_FILL` / `CONTESTED_STROKE` and a `"Contested: A vs B"` hover sub-line; the contested check runs **first** (before unowned / owned) in the fill-resolution, and the hover handler resolves the party names from `GameState.contested`. **The map legend stays at 7 items** (it is derived from the `RegionMap` owners only — a contested key is absent from the `RegionMap`, so it adds no legend entry — e2e legend assertions unaffected). `App.tsx` passes `contested={game.contested ?? []}` to `<WorldMap>`.
- **Registry & prompt.** `contested` is added **last** in `ABILITIES` (existing order untouched — the 12th entry); the system prompt gains the `contested` schema line and its constraint bullet ("a contested region is in NO nation's territory (never two) and is never no man's land while contested; at most 1–2 contested-region changes per turn, each narrated in `events`"); the user prompt gains `"contested"` (region key + party **names**) **only when the list is non-empty** (the one durable ability whose `payload()` can be `{}`). The two committed system-prompt **goldens** (`system-no-tool.txt`, `system-tool.txt`) are regenerated (each gains exactly the new `contested` schema line after `sea` + constraint bullet before the "Prefer 1-4 events" bullet); the **`user.txt` golden gains a `"contested"` entry** (the sample `b-r1` frontline with party names) as a new key after `seaZones`.
- **Tests.** New `server/abilities/contested.test.ts` (**52 tests**): the `CONTESTED_FILL` / `CONTESTED_STROKE` / `contestedTooltipLabel` surface; `validateContestedOps` (shape / leniency / drops / field caps / malformed top-level); `applyContested` OPEN (sole-owner-at-war with owner-as-party / party-not-owner, owner-not-at-war drop, no-man's-land open + NML-with-warring-parties + not-in-unowned drop, corrupt 2-owner drop, armistice-not-a-war drop, distinct-party / unresolvable / ambiguous drops, region-name + party-name resolve, manifest-gated key drop, owner stats re-derive + 0-region guard, two-opens-on-same-key, multi-key open); RESOLVE (release-to-NML, party-re-claim keeps + re-inits stats, non-party-owner release, multi-frontline, non-contested no-op, empty no-op); ordering (re-open replaces, resolve-then-open from NML, still-contested not re-openable, combined apply); and the ability const contract (field, `stateFields`, fresh `default()`/`stub()`, `isMeaningful`, `payload()` id→name / missing-nation fallback / `{}` when empty, `validate` wrapping, `apply` ctx wiring). Every apply test asserts the **sole-ownership invariants** (no key in >1 territory; a contested key in no territory and not unowned; an unowned key in no territory and not contested). `registry.test.ts` is updated for the **12-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields`, composes 13 / `extended[12]`, re-export pin, user-golden 15th arg). New `db.test.ts` legacy-row test (a pre-v0.44 save without `contested` loads with `[]`).
- **Design note (deviation from the issue body).** The Gitea #66 body sketched a **two-owner** design (`disputed` regions with `fronts`, explicitly "relaxing sole-ownership"). The approved, implemented design is a **third region state**: a contested key is in **no** nation's `territory` and not in `unowned` (sole ownership is preserved — a key is never in two territories), with `open` / `resolve` ops and a **minimum of two contesting parties** tied to an active war. This keeps the engine's sacrosanct sole-ownership invariant intact while still modelling a war frontline.
- **Guarantees.** No new runtime deps, `shared/` keeps zero runtime deps, no `@turf`/`@types/geojson` in any `.ts`. No engine randomness (the LLM declares; the engine applies fixed, deterministic rules). All existing unit tests green (additive); e2e (CI, `LLM_STUB=1`) unaffected (the stub is contested-inert — it never opens or resolves a frontline, and the map legend stays at 7).
- **Version** bumped `0.43.0 → 0.44.0`.
## 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.**
- `types.ts` — the `Ability<K extends AbilityField>` interface (a self-contained per-field module: `field`, `default()`, `validate()`, `apply()`, `schemaLine()`, `constraint()`, `isMeaningful()`, `stub()`, `payload()`, optional `stateFields`) plus the `AbilityField` union (the closed set of the 8 fields), `AbilityCtx`, and `UserPromptCtx` type surfaces.
- `shared.ts` — the cross-ability pure helpers (`resolveNationRef`, `resolveRegionKey`, `freshNationId`, `freshWarId`, `NEW_NATION_STATS`).
- `apply-helpers.ts` — the war-relation sync helpers (`activeWarPairs`, `atWar`, `syncWarRelations`).
- One file per ability — `takeovers.ts`, `wars.ts`, `hybrid.ts`, `economy.ts`, `industry.ts`, `nuclear.ts`, `leaders.ts`, `ideology.ts` — each owning its `Ability<K>` const (validator, applier, schema line, constraint bullet, meaningfulness predicate, inert stub default, user-payload keys, and — for wars / economy / industry — the `GameState` fields it owns).
- `index.ts` — assembles the ordered `ABILITIES` registry (`takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology`) and re-exports every public name the engine and its tests need.
- **Engine (`server/engine.ts`).** Every per-ability code path now iterates `ABILITIES` instead of hand-enumerating the eight fields: `buildSystemPrompt` (schema lines + constraint bullets), `buildUserPrompt` (payload keys at their exact historical positions), `validateTurnResult` (per-field validate), `applyTurnResult` (the ordered apply block), `isMeaningful`, and the `runTurn` "unclear" fallback. The ~1,249 lines of moved ability bodies are deleted; the 21 names its callers import are re-exported from `./abilities` (5 shared primitives + 16 ability-module exports).
- **Stub (`server/stub.ts`).** `deterministicTurn` fills the 8 ability fields from the registry (each contributes its inert default) between the core fields and `markers` — the JSON output stays byte-identical to v0.39.0 (verified against `origin/master`).
- **Persistence (`server/db.ts`).** The legacy-row migration for ability-owned `GameState` fields (wars / trades / embargoes / industry) iterates the registry's `stateFields` (only-if-undefined) instead of a hardcoded field list.
- **Tests.** New `server/abilities/registry.test.ts` (11 tests): the committed **golden prompts** (`server/abilities/goldens/` — system no-tool / system tool / user) are asserted **byte-identical** to the pre-refactor engine's output (a `REGEN_GOLDENS=1` mode regenerates them after an intentional prompt change — verified a no-op); the registry is the canonical 8-ability order with 8 distinct `TurnResult` fields; each `default()` equals the canonical `TurnResult` default and is a **fresh** value (never a shared reference); the default is never "meaningful" while a populated value is; the registry declares exactly the durable `GameState` fields; the registry composes (a hypothetical 9th ability appends cleanly to every uniform loop); and the engine still re-exports every name its callers import.
- **Guarantees.** No client (`src/`) changes, no new runtime deps, no `@turf`/`@types/geojson` in any `.ts`, and `shared/` keeps zero runtime deps. All 522 unit tests green; e2e (CI, `LLM_STUB=1`) unaffected.
- **Version** bumped `0.39.0 → 0.40.0`.
## v0.39.0 — 2026-10-15 20:51
- **#62: Ideology / form of government — a per-nation `ideology` + a `TurnResult.ideology` lever (coups / revolutions / reforms / constitutional changes) with a deterministic −5 stability dip and political narration.** Each nation may carry a free-form `ideology` label — its form of government ("democracy", "fascism", "military junta", …). The model changes it each turn via `ideology.changes`; each change names a target nation (id or name) and the new government type. The engine sets the label and applies a **deterministic** stability dip (a regime shift always unsettles a nation) — **no hidden randomness**, **additive** (no `db.ts` change — `ideology` is an optional `Nation` field; a pre-v0.39 row loads with it absent, i.e. "no recorded government"). It is a third, independent axis next to `leader` (who is in charge) and the v0.32.0 takeover (which renames the nation): the id, name, territory, relations, and the leader all stay valid — only the form of government and stability move.
- **Types (`shared/types.ts`).** `Nation.ideology?: string` (optional; `undefined`/absent = no recorded government).
- **Protocol (`shared/protocol.ts`).** New `IdeologyChange` (`{ target; to }` — `target` is a nation ref, `to` is the new form of government), `IdeologyOps` (`{ changes: IdeologyChange[] }`); `TurnResult.ideology` is **required**, placed between `leaders` and `markers`, default `{ changes: [] }`. New exported **`IDEOLOGY_STABILITY_EFFECT = −5`** (the deterministic stability dip of a regime shift). `nationForPrompt` now includes the nation's `ideology` (omitted when absent via `JSON.stringify`), so `buildUserPrompt` carries each nation's current government with no separate wiring.
- **Engine (`server/engine.ts`).**
- `validateIdeologyOps(raw)` — new exported, defensive validator (never throws): accepts a `{ changes: [...] }` object or a bare array; each change needs a non-empty `target` (trimmed, ≤120) and a non-empty, non-whitespace `to` (trimmed, ≤120) — anything else is dropped. Refs stay raw (resolved at apply time, like wars / hybrid / nuclear / leader ops).
- `applyIdeologyChange(next, op, geoOf?, actingTurn?)` — new, mirrors `applyLeaderChange` (single-stat, nation-target). Resolves the target (unknown / ambiguous → dropped, never thrown). On a valid change it sets `n.ideology = to` and applies `IDEOLOGY_STABILITY_EFFECT` (−5) to `n.stats.stability` (clamped 0–100) mirrored to every region the nation owns (`regionStats`), then re-derives the nation's stats (the same 0-region / no-entry guards as hybrid / economy / industry / leaders, so a 0-region nation keeps its directly-mutated value). Re-setting the **same** label is idempotent for the `ideology` field but still narrated and still dips (the shift just happened). Finally it appends a **`political`** narration event (`"<nation>'s government shifted to <to>"`). The change never moves territory (sole ownership is untouched).
- `applyTurnResult` — the ideology block runs **after `leaders` and before `markers`** (order: events → relations → territory → stateChanges → recompute → takeovers → wars → hybrid → economy → industry → nuclear → leaders → **ideology** → markers → nationOps).
- `isMeaningful` — a non-empty `ideology.changes` makes a turn meaningful.
- `validateTurnResult` — initializes and validates `ideology` (default `{ changes: [] }`).
- `buildSystemPrompt` — documents the `ideology` field (free-form label, the −5 dip, the "at most 1–2 per turn, narrated" rule) and contrasts it with a takeover (which RENAMES the nation) and a leader change (who is in charge); similar forms of government are a **soft, prompt-level compatibility nudge only** (alike ideologies tend to ally/trade, opposed ones tend to clash) — there is no engine gate behind it.
- `runTurn` — the "unclear" fallback carries `ideology: { changes: [] }`.
- **Stub (`server/stub.ts`).** `deterministicTurn` always returns `ideology: { changes: [] }` and the `StubPayload` nations type gains an optional `ideology` — the stub **never** changes an ideology, so the e2e (CI, `LLM_STUB=1`) is unaffected and the path is covered by unit tests only.
- **Tests.** New unit tests in `engine.test.ts`: `validateIdeologyOps` (`{changes}` object, bare array, empty/whitespace `to` drop, missing/whitespace target drop, malformed top-level, mixed batch keeps only valid, trim + 120-cap); `isMeaningful` (an ideology change is meaningful, empty ideology is not); `applyTurnResult` (a valid change → field set + 50→45 + `political` event + region mirror, other nation untouched; re-setting the same label → one dip, one event; a multi-region nation → every region dipped and stats re-derived; a target resolved by name as well as id; a 0-region nation keeps its directly-dipped stat; a dip clamped at 0; an unknown target and an ambiguous (two-nation-same-name) target → dropped without throwing); `buildUserPrompt` (a set ideology is stamped in the prompt nations, an absent one is omitted). `protocol.test.ts`: the `IDEOLOGY_STABILITY_EFFECT` constant is −5 and `nationForPrompt` includes/omits the `ideology` key in the JSON. Two `db.test.ts` round-trips: a legacy row with no ideology loads with it absent, and a set ideology round-trips through save/load.
- **Version** bumped `0.38.0 → 0.39.0`.
## v0.38.0 — 2026-10-15 20:06
- **#61: Named leaders — a per-nation `leader` + a `TurnResult.leaders` lever (assassination / death / appointment) with a deterministic stability effect and succession narration.** Each nation may carry a single named head of state (`Nation.leader`); the model changes it each turn via `leaders.changes`. The engine is **fully deterministic (no hidden randomness)** and **additive** (no `db.ts` change — `leader` is an optional `Nation` field; a pre-v0.38 row loads with it absent, i.e. "no leader").
- **Types (`shared/types.ts`).** `Nation.leader?: { name: string }` (optional; `undefined`/absent = the nation has no leader).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.38.0",
"version": "0.44.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+88
View File
@@ -0,0 +1,88 @@
// Shared, cross-ability `apply` primitives (v0.40.0, #67): the war-derived
// relation machinery that several abilities share. Two abilities (wars,
// nuclear) mutate `GameState.wars` and both need the same "which pairs are at
// war" primitive (`activeWarPairs` / `atWar`); the wars ability applies the
// turn's declare/end ops and then runs the war → relation sync
// (`syncWarRelations`), and the nuclear ability's `endWarsBetween` (which
// lives in ./nuclear.ts) reuses `activeWarPairs` to downgrade the relations a
// just-ended war no longer covers.
//
// The bodies here are moved VERBATIM from the pre-refactor engine.ts (v0.39.0)
// — `engine.ts` re-exports all three so existing callers (server/index.ts, the
// unit tests) keep importing them from the engine unchanged. All functions are
// pure or mutate their `GameState` argument in place and never throw.
import type { GameState, War } from '../../shared/types';
/**
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
* {b,c} (any two distinct belligerents of the same active war are at war —
* see the War type's semantics). Pure; exported for the unit tests.
*/
export function activeWarPairs(wars: War[]): Set<string> {
const pairs = new Set<string>();
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
for (const w of wars) {
if (w.status !== 'active') continue;
for (let i = 0; i < w.sides.length; i++) {
for (let j = i + 1; j < w.sides.length; j++) {
pairs.add(key(w.sides[i], w.sides[j]));
}
}
}
return pairs;
}
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
export function atWar(wars: War[], a: string, b: string): boolean {
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
return activeWarPairs(wars).has(key);
}
/**
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
* applyTurnResult does) and never touching `prev`. For every unordered pair
* of distinct existing nations:
*
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
* of the pair's relation are set to `war` (the engine owns this relation
* while the war is active — a manual `relations` entry to a lesser status
* on a warring pair is overridden, so the two can never "make peace"
* without the war being ended);
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
* wars that tied them ended this turn) AND the current relation is
* `war` → both directions become `hostile` (a war is the strongest
* hostility — when it stops, the nations stay hostile, not suddenly
* neutral);
* - otherwise (not at war now, and the relation is not `war`, or the pair
* was never tied by an active war) → untouched: the LLM owns
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
* no active war ever covered (a preset leftover) stays as-is — an
* implicit, unnamed war, never force-created into a War entity and never
* clobbered by the sync.
*
* Never throws.
*/
export function syncWarRelations(prev: GameState, next: GameState): void {
const prevAtWar = activeWarPairs(prev.wars ?? []);
const nextAtWar = activeWarPairs(next.wars ?? []);
const ids = Object.keys(next.nations);
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = ids[i];
const b = ids[j];
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
const na = next.nations[a];
const nb = next.nations[b];
if (nextAtWar.has(key)) {
na.relations[b] = 'war';
nb.relations[a] = 'war';
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
na.relations[b] = 'hostile';
nb.relations[a] = 'hostile';
}
}
}
}
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
// The CONTESTED (contested regions / frontlines) ability (v0.44.0, #66) as a
// modular LLM-ability (v0.40.0, #67) — a THIRD region state, besides "owned by
// one nation" and "no man's land": a war frontline that is in **no** nation's
// `territory` and not in `unowned`, contested by two or more nations at war.
//
// Each turn the LLM may `open` a region as a contested frontline and/or
// `resolve` an existing one. `open` requires the region to have a SOLE owner
// that is at war with at least one of the parties, or to be no man's land (in
// `unowned`) with two or more of the parties at war with each other; a region
// with two or more owners, or whose owner is not at war with any party, cannot
// be contested (dropped). On a successful open the key is removed from its
// owner's `territory` (sole ownership is preserved — a contested key is in NO
// territory) and from `unowned`, its `regionStats` entry is deleted, and a
// `{ regionKey, parties }` entry (parties = resolved nation ids) is added to
// `GameState.contested` (unique by `regionKey` — re-opening replaces it).
// `resolve` removes the entry and releases the key to no man's land — UNLESS a
// concurrent winning claim by one of the parties (allowed by the
// territory-step guard in engine.ts) already re-owned it, in which case it
// stays owned and its stats are kept.
//
// A still-contested key is NOT a valid normal-claim target: a plain `gain` on
// it is dropped in the territory step (only a resolving party's claim
// re-owns it). The layer NEVER moves a key into two territories (sole
// ownership is sacrosanct), never touches borders directly (the key simply
// leaves ownership), and is deterministic (no hidden randomness — the LLM
// declares, the engine applies fixed rules).
//
// This is a DURABLE ability (unlike the turn-transient hybrid / disasters /
// migration layers): it owns the `GameState.contested` field, and the user
// payload carries the current contested list (only when non-empty).
import { deriveNationStats } from '../../shared/protocol';
import type { ContestedOps, ContestedRegion } from '../../shared/protocol';
// The map-fill constants + tooltip label live in the shared protocol module
// (like the sea-zone / no-man's-land constants); re-exported here so the
// index / engine re-export surface is one consistent place.
export {
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import { atWar } from './apply-helpers';
import { resolveNationRef, resolveRegionKey, str } from './shared';
import type { Ability, AbilityCtx } from './types';
/**
* (v0.44.0, #66) The SOLE owners of a region key in `next` (a key that is in no
* nation's `territory` yields `[]`; a corrupt 2+ owner key yields both). Pure.
*/
function currentOwners(next: GameState, key: string): string[] {
const owners: string[] = [];
for (const n of Object.values(next.nations)) {
if (n.territory.includes(key)) owners.push(n.id);
}
return owners;
}
/**
* (v0.44.0, #66) Resolve a region reference (a raw key or a human name) to a
* concrete region key: exact key first, then a unique case-insensitive name
* match. With no region-key manifest loaded (`validKeys` undefined) the raw
* string is accepted as the key (a name cannot be resolved without the
* manifest). Returns `null` when the reference is empty or unresolvable.
*/
function resolveKey(
ref: string,
validKeys?: ReadonlySet<string>,
nameOf?: (key: string) => string | undefined,
): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (!validKeys) return r; // no manifest: accept the raw key as-is
return resolveRegionKey(r, validKeys, nameOf ?? (() => undefined));
}
/**
* (v0.44.0, #66) Resolve a list of party references to distinct nation ids
* (id first, then a case-insensitive unique name), preserving first-appearance
* order. An unresolvable ref is dropped. Pure.
*/
function resolveParties(next: GameState, refs: string[]): string[] {
const out: string[] = [];
for (const ref of refs) {
const id = resolveNationRef(next, ref);
if (id && !out.includes(id)) out.push(id);
}
return out;
}
/**
* (v0.44.0, #66) Re-derive a nation's aggregate `stats` from its regions'
* per-region stats, with the same 0-region / no-entry guards as the engine's
* main recompute (a nation with no region, or none with a stats entry, keeps
* its directly-mutated `stats`). Used after a contested open strips a region
* from its owner. Never throws.
*/
function rederiveOwnerStats(
next: GameState,
ownerId: string,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
const n = next.nations[ownerId];
if (!n) return;
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
/**
* (v0.44.0, #66) Validate the raw `contested` field of a turn into a safe
* {@link ContestedOps}. Defensive like the rest of the parser — never throws.
* `open`: each op needs a non-empty `regionKey` (capped at 120 chars) and
* TWO OR MORE non-empty `parties` refs (each capped at 200 chars); an op with
* a missing `regionKey` or fewer than two party refs is dropped. `resolve`:
* each entry is a non-empty region key (capped at 120 chars); empty / non-
* string entries are dropped. Both region keys and party refs are kept RAW
* here (like the hybrid / economy / industry refs) — whether a ref resolves to
* a real region / nation, and whether the war-linkage / ownership conditions
* hold, is checked at apply time (a ref that does not resolve, or a region that
* is not contestable, is dropped there). A missing / malformed top-level object
* yields `{ open: [], resolve: [] }`.
*/
export function validateContestedOps(raw: unknown): ContestedOps {
const out: ContestedOps = { open: [], resolve: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.open)) {
for (const e of o.open) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const regionKey = str(p.regionKey, 120);
if (!regionKey) continue; // missing / empty region key: drop
if (!Array.isArray(p.parties)) continue; // missing / malformed parties: drop
const parties = p.parties
.map((x) => str(x, 200))
.filter((x): x is string => x !== null);
if (parties.length < 2) continue; // need at least two party refs
out.open.push({ regionKey, parties });
}
}
if (Array.isArray(o.resolve)) {
for (const e of o.resolve) {
const key = str(e, 120);
if (key) out.resolve.push(key);
}
}
return out;
}
/**
* (v0.44.0, #66) Apply the turn's contested-region ops to `next`, mutating in
* place and never throwing. Runs in the `ABILITIES` loop AFTER the territory
* step and the derived-stats recompute, and BEFORE markers / nationOps, so the
* owner / unowned / wars it inspects are all final for this turn.
*
* (1) RESOLVE (first): each key in `resolve` that is currently contested is
* removed from `next.contested` and released to no man's land (into
* `unowned`, out of any territory, `regionStats` deleted) — UNLESS a
* concurrent winning claim by one of the parties (the only claim the
* territory step permits on a contested key) already re-owned it, in
* which case it stays owned and keeps (or re-inits) its stats.
* (2) OPEN: each op that resolves to a contestable region (a sole owner at
* war with a party, or a no-man's-land region whose parties are at war
* with each other) is moved out of ownership (the owner's territory /
* `unowned`), its `regionStats` is deleted, and it is added to
* `next.contested` (unique by `regionKey` — an open replaces a kept
* entry for the same key). The stripped owner's derived stats are
* re-derived.
*
* A contested key is never placed into two territories and is never left in
* `unowned` (the two states are mutually exclusive), so the sole-ownership
* invariant holds after every op.
*/
export function applyContested(next: GameState, ops: ContestedOps, ctx: AbilityCtx): void {
const validKeys = ctx.validRegionKeys;
const nameOf = ctx.regionNameOf;
const geoOf = ctx.geoOf;
const open = Array.isArray(ops?.open) ? ops.open : [];
const resolve = Array.isArray(ops?.resolve) ? ops.resolve : [];
const prevContested = Array.isArray(next.contested) ? next.contested : [];
// (1) RESOLVE first: rebuild the kept list, dropping resolved keys. A
// resolved key is released to no man's land unless a party re-owned it.
const resolveSet = new Set(resolve);
const kept: ContestedRegion[] = [];
for (const c of prevContested) {
if (!resolveSet.has(c.regionKey)) {
kept.push(c); // not resolved this turn: keep
continue;
}
const owners = currentOwners(next, c.regionKey);
if (owners.length === 1 && c.parties.includes(owners[0])) {
// A party re-owned it via a concurrent winning claim (the territory step
// allowed it): keep it owned, ensure it has stats (the claim re-inits an
// absent entry; the post-claim recompute already reflected the gain).
if (!next.regionStats[c.regionKey]) {
next.regionStats[c.regionKey] = { ...next.nations[owners[0]].stats };
}
continue; // stays owned — not re-added to `contested`
}
// Release to no man's land: out of any territory (defensive — a contested
// key is normally in none), into `unowned`, and its per-region stats leave.
for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(c.regionKey);
if (i !== -1) n.territory.splice(i, 1);
}
if (!next.unowned.includes(c.regionKey)) next.unowned.push(c.regionKey);
delete next.regionStats[c.regionKey];
}
// (2) OPEN: each op that resolves to a contestable region is moved out of
// ownership and added to the contested list (unique by region key).
const opened = new Map<string, string[]>(); // region key -> resolved party ids
for (const op of open) {
const key = resolveKey(op.regionKey, validKeys, nameOf);
if (!key) continue;
const parties = resolveParties(next, op.parties);
if (parties.length < 2) continue; // fewer than two distinct resolved parties
const owners = currentOwners(next, key);
let ok = false;
if (owners.length === 1) {
// Sole owner present: the owner must be at war with at least one party
// (the owner may itself be a party — atWar is false for a==b, so this
// checks the owner against the OTHER parties).
ok = parties.some((p) => atWar(next.wars, owners[0], p));
} else if (owners.length === 0 && next.unowned.includes(key)) {
// No owner + no man's land: two of the parties must be at war with
// each other.
for (let i = 0; i < parties.length && !ok; i++) {
for (let j = i + 1; j < parties.length; j++) {
if (atWar(next.wars, parties[i], parties[j])) {
ok = true;
break;
}
}
}
}
// Two or more owners (corrupt), an owner not at war with any party, or a
// not-in-unowned keyless region: never contestable (dropped).
if (!ok) continue;
if (owners.length === 1) {
const n = next.nations[owners[0]];
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
rederiveOwnerStats(next, owners[0], geoOf);
} else {
const i = next.unowned.indexOf(key);
if (i !== -1) next.unowned.splice(i, 1);
}
delete next.regionStats[key];
opened.set(key, parties);
}
// Rebuild `next.contested`: the kept (unresolved) entries plus the newly
// opened ones, unique by region key (an open replaces a kept entry for the
// same key; a resolve removed its key from `kept` above).
const final: ContestedRegion[] = [];
const seen = new Set<string>();
for (const c of kept) {
if (opened.has(c.regionKey)) continue; // replaced by a re-open this turn
if (seen.has(c.regionKey)) continue; // dedupe (should not happen, but safe)
seen.add(c.regionKey);
final.push(c);
}
for (const [key, parties] of opened) {
if (seen.has(key)) continue;
seen.add(key);
final.push({ regionKey: key, parties });
}
next.contested = final;
}
export const contested: Ability<'contested'> = {
field: 'contested',
default: () => ({ open: [], resolve: [] }),
validate: (raw) => validateContestedOps(raw),
apply: (state, data, ctx) => applyContested(state, data, ctx),
schemaLine: () =>
' "contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},',
constraint: () =>
'- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man\'s land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner\'s territory (or no-man\'s-land) and is contested: the region\'s sole owner must be at war with at least one of the parties, or (for a no-man\'s-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man\'s land — unless a party\'s concurrent claim re-owns it. A contested region is in NO nation\'s territory (never two) and is never no man\'s land while contested. The user message\'s "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event).',
isMeaningful: (data) => data.open.length > 0 || data.resolve.length > 0,
stateFields: ['contested'],
stub: () => ({ open: [], resolve: [] }),
// The user payload carries the current contested frontlines (region key +
// party NAMES, resolved from the stored ids) — but only when non-empty (a
// fresh / no-frontline world contributes nothing, like the turn-transient
// layers).
payload: (ctx) => {
if (!ctx.contested || ctx.contested.length === 0) return {};
return {
contested: ctx.contested.map((c) => ({
regionKey: c.regionKey,
parties: c.parties.map((id) => ctx.state.nations[id]?.name ?? id),
})),
};
},
};
+449
View File
@@ -0,0 +1,449 @@
// 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
/** 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: () => ({}),
};
+229
View File
@@ -0,0 +1,229 @@
// The ECONOMY (trade & embargo) ability (v0.35.0, #58) as a modular
// LLM-ability (v0.40.0, #67). A bilateral trade gives BOTH parties a small,
// deterministic economy gain every committed turn; a unilateral embargo
// lowers only the TARGET's economy every committed turn. Ops are idempotent
// (a no-`lift` op declares / imposes; `lift: true` ends / lifts; refs
// resolved against the real state at apply time — unknown / ambiguous /
// self-pairing ops are dropped, never thrown). Every committed turn the FULL
// active lists apply their effect (clamped, mirrored to each affected
// nation's regions, re-derived), and any trade/embargo between nations on
// opposite sides of an active war is auto-suspended (a war severs the
// economic link). All bodies are moved VERBATIM from the pre-refactor
// engine.ts.
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import type { EconomyEmbargoOp, EconomyOps, EconomyTradeOp } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import { activeWarPairs } from './apply-helpers';
import type { Ability } from './types';
/**
* (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe
* {@link EconomyOps}. Defensive like the rest of the parser — never throws.
* `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique
* name, capped at 200 chars) and a boolean `lift` (default false = declare);
* ops with a missing ref are dropped — ref resolution and the
* a≠b / from≠target checks happen at apply time against the real state
* (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op
* needs non-empty `from` and `target` refs and a boolean `lift` (default
* false = impose). A missing / malformed top-level object yields
* `{ trade: [], embargo: [] }`.
*/
export function validateEconomyOps(raw: unknown): EconomyOps {
const out: EconomyOps = { trade: [], embargo: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.trade)) {
for (const e of o.trade) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const a = str(t.a, 200);
const b = str(t.b, 200);
if (!a || !b) continue;
const op: EconomyTradeOp = { a, b };
if (t.lift === true) op.lift = true;
out.trade.push(op);
}
}
if (Array.isArray(o.embargo)) {
for (const e of o.embargo) {
if (!e || typeof e !== 'object') continue;
const t = e as Record<string, unknown>;
const from = str(t.from, 200);
const target = str(t.target, 200);
if (!from || !target) continue;
const op: EconomyEmbargoOp = { from, target };
if (t.lift === true) op.lift = true;
out.embargo.push(op);
}
}
return out;
}
/**
* (v0.35.0, #58) The per-turn economic effect of the economy layer, applied
* EVERY committed turn (not just to this turn's declares): each active
* bilateral trade grants both parties a small, deterministic economy gain;
* each active embargo applies a small, deterministic economy dip to the
* target only. Clamped to 0–100. The effect lands on `stats.economy` AND on
* every region the nation owns (`regionStats`), mirroring the hybrid-op
* effect, and each affected nation's derived stats are re-derived afterwards
* (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there
* are no active trades or embargoes. Never throws.
*/
const TRADE_ECONOMY_BONUS = 1;
const EMBARGO_ECONOMY_PENALTY = 1;
export function applyEconomyEffects(
next: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
// nation id -> signed economy delta (trades add for both parties, embargoes
// subtract for the target; deltas accumulate for nations hit by several).
const delta = new Map<string, number>();
for (const t of next.trades) {
for (const id of [t.a, t.b]) {
delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS);
}
}
for (const e of next.embargoes) {
delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY);
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue; // a nation removed this turn (a merge): the delta is dropped
const cur = clampStat(n.stats.economy + d);
if (cur === n.stats.economy) continue; // no change (already at the clamp)
n.stats.economy = cur;
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
/**
* (v0.35.0, #58) Apply the turn's economy ops to the active lists on
* `next`, RESOLVING the (possibly name-based) refs against the current
* state: a no-`lift` trade op declares the bilateral agreement (idempotent —
* an already-active pair is not duplicated; the pair is stored sorted, since
* a trade is symmetric); `lift: true` ends it (a no-op when absent). A no-
* `lift` embargo op imposes the unilateral embargo (idempotent — the ordered
* from→target pair is kept as-is); `lift: true` lifts it. An op whose refs
* don't resolve to distinct EXISTING nations is dropped (defensive — never
* throws). Mutates `next` in place.
*/
function applyEconomyOps(next: GameState, ops: EconomyOps): void {
const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`;
for (const op of ops.trade) {
const a = resolveNationRef(next, op.a);
const b = resolveNationRef(next, op.b);
if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop
const key = tradeKey(a, b);
if (op.lift === true) {
next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key);
} else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) {
// stored sorted (a < b): a trade is symmetric, so the agreement has a
// canonical shape no matter which way the LLM named the pair.
next.trades.push(a < b ? { a, b } : { a: b, b: a });
}
}
for (const op of ops.embargo) {
const from = resolveNationRef(next, op.from);
const target = resolveNationRef(next, op.target);
if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop
const key = embargoKey(from, target);
if (op.lift === true) {
next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key);
} else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) {
next.embargoes.push({ from, target });
}
}
}
/**
* (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any
* active trade or embargo whose two nations are on OPPOSITE sides of an
* active war (v0.33, #56) is removed — a war severs the economic link, so
* the suspended agreement stops producing its per-turn effect from now on.
* A trade/embargo between two non-belligerents, or between two nations on
* the SAME side, is untouched. Mutates `next` in place; idempotent. Must be
* called AFTER the war declare/end ops of the turn (so it sees the final
* `next.wars`). Never throws.
*/
function suspendEconomyOnWar(next: GameState): void {
if (next.trades.length === 0 && next.embargoes.length === 0) return;
const warring = activeWarPairs(next.wars);
const atWarNow = (x: string, y: string): boolean => {
if (x === y) return false;
const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`;
return warring.has(key);
};
next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b));
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
}
export const economy: Ability<'economy'> = {
field: 'economy',
default: () => ({ trade: [], embargo: [] }),
validate: (raw) => validateEconomyOps(raw),
apply: (state, data, ctx) => {
// Placed after the wars block (so the auto-suspension sees the FINAL
// next.wars, including wars declared this turn) and before markers /
// nationOps (which never touch the economy lists). Order:
// (1) APPLY OPS: the turn's declare/lift ops mutate the active
// GameState.trades / GameState.embargoes lists (a declare is
// idempotent; refs resolved against the current state, unknown /
// ambiguous / self-pairing ops dropped).
// (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two
// nations are on opposite sides of an active war is removed —
// a war severs the economic link, so the suspended agreement
// stops producing its per-turn effect from now on (this also
// drops a trade declared this turn between two nations that just
// went to war).
// (3) PER-TURN EFFECT: the FULL active lists (not just this turn's
// declares) each apply their deterministic effect this turn —
// trade: both parties' economy up a little; embargo: the
// target's economy down a little — clamped, mirrored to the
// owning nations' regions, and re-derived like the hybrid dip.
// Additive only: declares add, lifts remove, and nothing else (not even
// a war) mutates the lists besides the suspension above.
applyEconomyOps(state, data);
suspendEconomyOnWar(state);
applyEconomyEffects(state, ctx.geoOf);
},
schemaLine: () =>
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
constraint: () =>
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
isMeaningful: (data) => data.trade.length > 0 || data.embargo.length > 0,
stateFields: ['trades', 'embargoes'],
stub: () => ({ trade: [], embargo: [] }),
payload: (ctx) => {
const out: Record<string, unknown> = {};
if (ctx.trades !== undefined) out.trades = ctx.trades;
if (ctx.embargoes !== undefined) out.embargoes = ctx.embargoes;
return out;
},
};
@@ -0,0 +1,81 @@
WORLD: World War II — 1939-09-01
It is early September 1939. Germany has just invaded Poland, and Britain and France have declared war. Europe stands on the edge of the largest conflict the world has ever seen. The Axis powers — Germany, Italy, and Japan — are pressing their advantage across Europe and the Pacific, while the Allies — Britain, France, the Soviet Union, and a still-neutral but industrially immense United States — are marshalling their strength.
The balance of power is delicate. Germany's army is the most battle-hardened force on the continent, but its resources are finite and its flanks exposed. Britain commands the seas but is an island nation. The Soviet Union has a vast army and deep territory but a fragile industrial base. The United States has unmatched economic power but is geographically isolated and politically divided over entry into the war.
You are the head of one of these nations. Every decision you make — military, economic, or diplomatic — ripples across the world and reshapes the map, the alliances, and the fate of hundreds of millions.
SIMULATION RULES
- Serious, historically-plausible tone; write like a war correspondent and a policy analyst combined.
- Prefer gradual change: 1-4 meaningful events per turn, never a single decisive world-ending event.
- Respect existing borders unless a war is declared or a decisive military shift occurs.
- No single nation may instantly dominate the world; power shifts are incremental.
- Respect the player's chosen nation's resources, manpower, and geography.
- Diplomacy can shift relations over time but rarely reverses a declared war in one turn.
- Keep stat values (military, economy, stability, population) between 0 and 100.
You are the world simulator. You advance the shared world state for ALL nations given the current state and the player's move.
Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:
{
"date": "YYYY-MM-DD (a few days after the current date)",
"events": [
{"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}
],
"stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
"regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
"relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],
"territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],
"unowned": ["<region key>"],
"nationOps": [
{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},
{"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}
{"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}
],
"takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],
"wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},
"hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},
"economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},
"industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},
"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>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)
- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender's military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man's land) region is never blocked this way.
- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.
- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation's other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.
HARD CONSTRAINTS
- Use only nation ids and region keys that exist in the provided state.
- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.
- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".
- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation's main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message's "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).
- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".
- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation's name.
- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation's region keys to at most one part — any key you leave unassigned goes to the first part.
- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.
- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.
- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair's relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message's "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".
- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target's economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor's relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region's sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".
- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET's economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message's "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.
- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region's owner a small, deterministic economy gain, the bigger the region's industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message's "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".
- 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).
- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" 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.
- Per-region stat changes ("regionChanges") target ONE region's stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation's regions; a "regionChanges" entry overrides it for that one region. The user message's "playerRegionStats" lists the player's own regions' current stats; use region keys from the "regions" table.
- Only change borders when war or a decisive political shift justifies it.
- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man's land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man's land.
- If a field is not relevant this turn, set it to an empty array.
+82
View File
@@ -0,0 +1,82 @@
WORLD: World War II — 1939-09-01
It is early September 1939. Germany has just invaded Poland, and Britain and France have declared war. Europe stands on the edge of the largest conflict the world has ever seen. The Axis powers — Germany, Italy, and Japan — are pressing their advantage across Europe and the Pacific, while the Allies — Britain, France, the Soviet Union, and a still-neutral but industrially immense United States — are marshalling their strength.
The balance of power is delicate. Germany's army is the most battle-hardened force on the continent, but its resources are finite and its flanks exposed. Britain commands the seas but is an island nation. The Soviet Union has a vast army and deep territory but a fragile industrial base. The United States has unmatched economic power but is geographically isolated and politically divided over entry into the war.
You are the head of one of these nations. Every decision you make — military, economic, or diplomatic — ripples across the world and reshapes the map, the alliances, and the fate of hundreds of millions.
SIMULATION RULES
- Serious, historically-plausible tone; write like a war correspondent and a policy analyst combined.
- Prefer gradual change: 1-4 meaningful events per turn, never a single decisive world-ending event.
- Respect existing borders unless a war is declared or a decisive military shift occurs.
- No single nation may instantly dominate the world; power shifts are incremental.
- Respect the player's chosen nation's resources, manpower, and geography.
- Diplomacy can shift relations over time but rarely reverses a declared war in one turn.
- Keep stat values (military, economy, stability, population) between 0 and 100.
You are the world simulator. You advance the shared world state for ALL nations given the current state and the player's move.
You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:
{
"date": "YYYY-MM-DD (a few days after the current date)",
"events": [
{"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}
],
"stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
"regionChanges": [{"region":"<region key or name>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],
"relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],
"territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],
"unowned": ["<region key>"],
"nationOps": [
{"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"},
{"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}
{"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}
],
"takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],
"wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},
"hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},
"economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},
"industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},
"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>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
}
HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)
- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender's military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man's land) region is never blocked this way.
- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.
- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation's other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.
HARD CONSTRAINTS
- Use only nation ids and region keys that exist in the provided state.
- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.
- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".
- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation's main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message's "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).
- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".
- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation's name.
- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation's region keys to at most one part — any key you leave unassigned goes to the first part.
- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.
- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.
- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair's relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message's "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".
- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target's economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor's relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region's sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".
- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET's economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message's "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.
- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region's owner a small, deterministic economy gain, the bigger the region's industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message's "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".
- 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).
- Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"<key>","parties":["<a>","<b>"]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op (["<key>"]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" 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.
- Per-region stat changes ("regionChanges") target ONE region's stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation's regions; a "regionChanges" entry overrides it for that one region. The user message's "playerRegionStats" lists the player's own regions' current stats; use region keys from the "regions" table.
- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.
- The "frontier" field of the user message lists region keys owned by other nations that border the player's territory — the natural targets of a player-led conquest this turn.
- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man's land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man's land.
- If a field is not relevant this turn, set it to an empty array.
+219
View File
@@ -0,0 +1,219 @@
{
"state": {
"turn": 4,
"date": "1940-02-14",
"playerNationId": "a1",
"nations": {
"a1": {
"id": "a1",
"name": "Alpha",
"type": "major",
"stats": {
"military": 60,
"economy": 55,
"stability": 70,
"population": 80
},
"territory": [
"a-r1",
"a-r2"
],
"relations": {
"b2": "ally",
"c3": "rival"
},
"leader": {
"name": "Alpha Leader"
},
"ideology": "democracy",
"nuclear": true
},
"b2": {
"id": "b2",
"name": "Bravo",
"type": "major",
"stats": {
"military": 75,
"economy": 40,
"stability": 20,
"population": 30
},
"territory": [
"b-r1",
"b-r2"
],
"relations": {
"a1": "ally"
},
"ideology": "fascism",
"nuclear": false
},
"c3": {
"id": "c3",
"name": "Charlie",
"type": "major",
"stats": {
"military": 30,
"economy": 35,
"stability": 80,
"population": 20
},
"territory": [
"c-r1"
],
"relations": {
"a1": "rival"
},
"nuclear": false
}
},
"recentEvents": []
},
"playerActions": [
"Rearm the northern border",
{
"to": "b2",
"message": "We can still talk terms, no?"
}
],
"pending": [
{
"kind": "action",
"text": "Rearm the northern border"
},
{
"kind": "diplomacy",
"to": "b2",
"text": "We can still talk terms, no?"
}
],
"roundInstruction": "This period the player (Alpha) takes the following actions and sends the following diplomatic messages: [\"Rearm the northern border\",{\"to\":\"b2\",\"message\":\"We can still talk terms, no?\"}]. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.",
"playerRegionStats": {
"a-r1": {
"military": 60,
"economy": 55,
"stability": 70,
"population": 80
},
"a-r2": {
"military": 58,
"economy": 53,
"stability": 72,
"population": 78
}
},
"regions": {
"a-r1": "Alpha North (US)",
"a-r2": "Alpha South (US)",
"b-r1": "Bravo Plain (RU)",
"b-r2": "Bravo Coast (RU)",
"c-r1": "Charlie Isle (FR)",
"x-unowned": "X Released (UN)"
},
"frontier": [
"b-r1"
],
"frontierMilitary": [
"[Your military: 60] vs [Bravo: 75] — a direct invasion could succeed"
],
"takeoverRisks": [
"Bravo (stability 20, a gathering is in progress)"
],
"unowned": [
"x-unowned"
],
"markers": [
{
"id": "mk0001",
"type": "gathering",
"name": "Bravo Rally",
"regionKey": "b-r1",
"owner": "b2"
},
{
"id": "mk0002",
"type": "rocket",
"name": "Charlie Silo",
"regionKey": "c-r1",
"owner": "c3"
},
{
"id": "mk0003",
"type": "capital",
"name": "Alpha Cap",
"regionKey": "a-r1",
"owner": "a1"
}
],
"wars": [
{
"id": "w1",
"sides": [
"a1",
"b2"
],
"aims": [
"control the border"
]
},
{
"id": "w2",
"sides": [
"b2",
"c3"
]
}
],
"relations": {
"b2": "ally",
"c3": "rival"
},
"trades": [
{
"a": "a1",
"b": "c3"
}
],
"embargoes": [
{
"from": "a1",
"target": "b2"
}
],
"resources": {
"a1": {
"oil": 0.5,
"iron": 0.2,
"food": 0.3,
"industry": 0.8
},
"b2": {
"oil": 0.1,
"iron": 0.9,
"food": 0.1,
"industry": 0.2
},
"c3": {
"oil": 0.2,
"iron": 0.3,
"food": 0.7,
"industry": 0.4
}
},
"industry": [
{
"regionKey": "a-r1",
"type": "industry"
}
],
"seaZones": [],
"contested": [
{
"regionKey": "b-r1",
"parties": [
"Alpha",
"Bravo"
]
}
]
}
+291
View File
@@ -0,0 +1,291 @@
// The HYBRID-WAR ability (v0.34.0, #57) as a modular LLM-ability (v0.40.0, #67).
// Covert, non-kinetic operations: the seven op types each have a DETERMINISTIC
// engine effect (no hidden randomness — the LLM declares the intended `outcome`
// and the engine acts only on that):
//
// spy informational only — no stat, no relation (pure intel)
// sabotage a small ECONOMY dip on the target
// assassinate a small STABILITY dip on the target (absent the named-leaders
// feature, #61, a leader's removal sours the polity)
// cyber a small MILITARY dip on the target
// economic a small ECONOMY dip on the target
// information a relation nudge toward hostile (cap hostile) — disinformation
// proxy a light relation nudge toward hostile (cap rival) — proxy war
//
// A successful stat op dips the target's `regionStats` (the single named region
// when the target was a region key, otherwise every region the target owns) and
// re-derives the target nation's aggregate stats so the effect persists. A
// successful information/proxy and any exposed op sour the actor's relation
// with the target by one step on the hostile ladder. All bodies are moved
// VERBATIM from the pre-refactor engine.ts.
import { clampStat, deriveNationStats, HYBRID_OP_TYPES, makeEvent } from '../../shared/protocol';
import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types';
import type { HybridOp, HybridOpType } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
const VALID_HYBRID_TYPES = new Set<string>(HYBRID_OP_TYPES);
/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */
export function isHybridOpType(t: unknown): t is HybridOpType {
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
}
/**
* (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe
* `{ ops }` shape. 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 `type` (one of
* {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at
* 200 chars) and a non-empty `target` (a nation id/name or a region key, capped
* at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the
* default, `success`, is stored as absent to keep the shape minimal — the
* engine treats a missing outcome as success); a non-empty `detail` is capped
* at 300 chars. Resolution of `from` / `target` to concrete nation ids or
* region owners happens at apply time (`applyHybridOp`), not here.
*/
export function validateHybridOps(raw: unknown): HybridOp[] {
if (!Array.isArray(raw)) {
// Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []).
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const inner = (raw as Record<string, unknown>).ops;
if (!Array.isArray(inner)) return [];
return validateHybridOpsList(inner);
}
return [];
}
return validateHybridOpsList(raw);
}
function validateHybridOpsList(raw: unknown[]): HybridOp[] {
const out: HybridOp[] = [];
for (const e of raw) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
if (!isHybridOpType(o.type)) continue;
const from = str(o.from, 200);
const target = str(o.target, 200);
if (!from || !target) continue;
const detail = str(o.detail, 300);
const exposed = o.outcome === 'exposed'; // 'success' / missing → success
out.push({
type: o.type,
from,
target,
...(detail ? { detail } : {}),
...(exposed ? { outcome: 'exposed' as const } : {}),
});
}
return out;
}
/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */
export const HYBRID_STAT_EFFECT: Record<HybridOpType, { stat: StatKey; delta: number } | null> = {
spy: null, // informational only
sabotage: { stat: 'economy', delta: -3 },
assassinate: { stat: 'stability', delta: -5 },
cyber: { stat: 'military', delta: -3 },
economic: { stat: 'economy', delta: -5 },
information: null, // relation nudge only
proxy: null, // relation nudge only
};
/**
* The hostile-relations ladder, weakest to strongest (the `cap` in
* {@link shiftRelationTowardHostile} is a member of this array). A missing
* relation is treated as `neutral`; `war` is never touched (a war is its own
* entity — a covert op neither fabricates nor ends one).
*/
const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
/**
* (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b`
* ONE step toward hostile (on the {@link HOSTILE_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 covert op
* neither fabricates nor downgrades a war). Used for the `information` /
* `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the
* caller passes a clone — applyTurnResult does). Never throws.
*/
export function shiftRelationTowardHostile(
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 = HOSTILE_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 = HOSTILE_ORDER.indexOf(s);
if (i === -1) return 'neutral'; // defensive: unknown status
if (i >= capIdx) return s; // at or past the cap: no change
return HOSTILE_ORDER[i + 1];
};
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.34.0, #57) The display name of a hybrid op's target for the auto
* backfire event: a nation target shows the nation's name; a region target
* shows the region's display name (or its raw key when no resolver is given).
*/
function hybridTargetDisplayName(
target: { nation: Nation; regionKey?: string },
regionNameOf?: (key: string) => string | undefined,
): string {
if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey;
return target.nation.name;
}
/**
* (v0.34.0, #57) Resolve a hybrid op'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 a stat op can
* dip just that region. `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.
*/
function resolveHybridTarget(
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 };
}
/**
* (v0.34.0, #57) Apply ONE hybrid 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 actor drops the op.
* 2. `target` must resolve to a concrete nation (a nation ref, or a region
* key's sole owner); an unresolvable target drops the op.
* 3. A self-target (`from` and the target nation are the same) is dropped.
* Then, per type:
* - an `exposed` op BACKFIRES: no intended effect, the actor's relation with
* the target sours by one step (cap hostile), and an auto diplomatic/
* failure event is emitted (the engine's own narrative, mirroring the
* "repelled" auto-event).
* - a successful stat op (sabotage / assassinate / cyber / economic) dips the
* target's `regionStats` by its per-type delta — the single named region
* when the target was a region key, otherwise every region the target
* owns — then re-derives the target nation's aggregate stats (the same
* 0-region / no-entry guards as the main recompute), so the effect
* persists to the next turn.
* - a successful `information` / `proxy` sours the actor's relation with the
* target by one step (information caps at hostile, proxy at rival).
* - `spy` is informational only (no stat, no relation) — a no-op except for
* whatever the LLM narrates in `events`.
*/
export function applyHybridOp(
next: GameState,
op: HybridOp,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
regionNameOf?: (key: string) => string | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const from = resolveNationRef(next, op.from);
if (from === null) return; // unknown / ambiguous actor: drop
const target = resolveHybridTarget(next, op.target, validKeys);
if (target === null) return; // unresolvable target: drop
if (from === target.nation.id) return; // self-target: drop
if (op.outcome === 'exposed') {
// Backfire: no intended effect; the relation sours by one step (cap
// hostile) and an auto diplomatic/failure event is emitted.
shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
const actor = next.nations[from];
next.events.push(
makeEvent(
{
nation: from,
type: 'diplomatic',
title: 'Covert operation exposed',
text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName(
target,
regionNameOf,
)} was exposed; relations sour in response.`,
outcome: 'failure',
},
turn,
next.date,
),
);
return;
}
const eff = HYBRID_STAT_EFFECT[op.type];
if (eff) {
// A successful stat op: dip the target's region(s), then re-derive.
if (target.regionKey !== undefined) {
const rs = next.regionStats[target.regionKey];
if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
} else {
for (const key of target.nation.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
}
}
// Re-derive the target nation's aggregate stats. The main recompute ran
// earlier this turn, so without this the dip would be washed out next
// turn. The same 0-region / no-entry guards as the main recompute apply.
if (target.nation.territory.length > 0) {
if (target.nation.territory.some((k) => next.regionStats[k])) {
target.nation.stats = deriveNationStats(
target.nation.territory,
next.regionStats,
geoOf,
);
}
}
return;
}
// information / proxy: a relation nudge (spy is a pure no-op here).
if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
}
export const hybrid: Ability<'hybrid'> = {
field: 'hybrid',
default: () => ({ ops: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ ops }` object (mirrors the historical `result.hybrid = { ops: ... }`).
validate: (raw) => ({ ops: validateHybridOps(raw) }),
apply: (state, data, ctx) => {
for (const op of data.ops) {
applyHybridOp(state, op, ctx.geoOf, ctx.validRegionKeys, ctx.regionNameOf, ctx.actingTurn);
}
},
schemaLine: () =>
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
constraint: () =>
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
isMeaningful: (data) => data.ops.length > 0,
stub: () => ({ ops: [] }),
payload: (ctx) => (ctx.relations !== undefined ? { relations: ctx.relations } : {}),
};
+123
View File
@@ -0,0 +1,123 @@
// The IDEOLOGY ability (v0.39, #62) as a modular LLM-ability (v0.40.0, #67).
// The model may change a nation's form of government (its `ideology` field) —
// a free-form label such as "democracy", "fascism", or "military junta" — via
// a coup, revolution, reform, or constitutional change. On a valid change the
// target's `ideology` is set, a DETERMINISTIC stability dip
// (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability`
// (clamped) and mirrored to every region the nation owns (`regionStats`), the
// nation's derived stats are re-derived afterwards (the same 0-region /
// no-entry guards), and a narration event is appended. The change never moves
// territory (sole ownership is untouched). All bodies are moved VERBATIM from
// the pre-refactor engine.ts.
import { deriveNationStats, makeEvent } from '../../shared/protocol';
import { clampStat, IDEOLOGY_STABILITY_EFFECT } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import type { IdeologyChange } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (v0.39, #62) Validate the raw `ideology` object of a turn into a safe
* `IdeologyChange[]`. Defensive like the rest of the parser — never throws.
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each
* change needs a non-empty `target` (a nation ref, capped at 120 chars) and
* a non-empty, non-whitespace `to` (the new form of government, capped at
* 120 chars). Refs stay raw here (resolved against the real state at apply
* time, like the wars / hybrid / nuclear / leader ops); an unresolvable
* target is dropped at apply time, never thrown.
*/
export function validateIdeologyOps(raw: unknown): IdeologyChange[] {
const out: IdeologyChange[] = [];
const list = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? (raw as Record<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const target = str(s.target, 120);
const to = str(s.to, 120);
if (!target || !to) continue;
out.push({ target, to });
}
return out;
}
/**
* (v0.39, #62) Apply a single ideology change to `next` (in place). The
* target nation is resolved by the shared resolver (id first, then a
* case-insensitive unique name; an unknown or ambiguous ref is dropped,
* never thrown). On a valid change the target's `ideology` is set to the new
* government type (re-setting the same value is idempotent for the field but
* still narrated) and a DETERMINISTIC stability dip
* (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability`
* (clamped 0–100) and mirrored to every region the nation owns
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
* same 0-region / no-entry guards as the hybrid/economy/industry/leader
* effects, so a 0-region nation keeps its directly-mutated value). Finally a
* narration event is appended: a `political` event for the government shift.
* The change never moves territory (sole ownership is untouched).
*/
function applyIdeologyChange(
next: GameState,
op: IdeologyChange,
geoOf?: (key: string) => { area: number; population: number } | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const id = resolveNationRef(next, op.target);
if (id === null) return; // unknown / ambiguous target: drop, never throw
const n = next.nations[id];
n.ideology = op.to;
const delta = IDEOLOGY_STABILITY_EFFECT;
if (delta !== 0) {
n.stats.stability = clampStat(n.stats.stability + delta);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs.stability = clampStat(rs.stability + delta);
}
// Re-derive the nation's aggregate stats (the main recompute ran earlier
// this turn, so without this the dip would be washed out next turn).
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
next.events.push(
makeEvent(
{
nation: n.id,
type: 'political',
title: `${n.name}'s government shifted to ${op.to}`,
text: `${n.name} has changed its form of government to ${op.to}.`,
outcome: 'success',
},
turn,
next.date,
),
);
}
export const ideology: Ability<'ideology'> = {
field: 'ideology',
default: () => ({ changes: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ changes }` object (mirrors the historical `result.ideology = { changes: ... }`).
validate: (raw) => ({ changes: validateIdeologyOps(raw) }),
apply: (state, data, ctx) => {
for (const ch of data.changes) applyIdeologyChange(state, ch, ctx.geoOf, ctx.actingTurn);
},
schemaLine: () =>
' "ideology": {"changes":[{"target":"<nation id or name>","to":"<new form of government>"}]},',
constraint: () =>
'- 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).',
isMeaningful: (data) => data.changes.length > 0,
stub: () => ({ changes: [] }),
payload: () => ({}),
};
+187
View File
@@ -0,0 +1,187 @@
// The ordered LLM-ability registry.
//
// `ABILITIES` is the single source of truth for the 12 per-feature LLM abilities.
// The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea → contested
// 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,
// (f) the runTurn unclear-fallback defaults, (g) the stub's deterministic fields, and
// (h) the db.ts state-field migrations.
//
// Each ability file defines its own `Ability<K>` const (the schema line, constraint
// bullet, validator, applier, meaningfulness predicate, stub default, and user-payload
// field). This module assembles them into the ordered `ABILITIES` array and re-exports
// every public name the engine (and its tests) need.
import { takeovers } from './takeovers';
import { wars } from './wars';
import { hybrid } from './hybrid';
import { economy } from './economy';
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 { contested } from './contested';
import type { Ability, AbilityField } from './types';
/**
* The 12 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. 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,
wars,
hybrid,
economy,
industry,
nuclear,
leaders,
ideology,
disasters,
migration,
sea,
contested,
];
// ── type re-exports ────────────────────────────────────────────────────────────
export type {
Ability,
AbilityCtx,
UserPromptCtx,
AbilityField,
} from './types';
// ── shared helpers ─────────────────────────────────────────────────────────────
export {
resolveNationRef,
resolveRegionKey,
freshNationId,
freshWarId,
NEW_NATION_STATS,
} from './shared';
// ── war helpers ────────────────────────────────────────────────────────────────
export {
activeWarPairs,
atWar,
syncWarRelations,
} from './apply-helpers';
// ── takeovers ──────────────────────────────────────────────────────────────────
export {
validateTakeovers,
applyTakeover,
} from './takeovers';
// ── wars ───────────────────────────────────────────────────────────────────────
export {
validateWarOps,
applyWars,
} from './wars';
// ── hybrid ─────────────────────────────────────────────────────────────────────
export {
validateHybridOps,
isHybridOpType,
HYBRID_STAT_EFFECT,
shiftRelationTowardHostile,
applyHybridOp,
} from './hybrid';
// ── economy ────────────────────────────────────────────────────────────────────
export {
validateEconomyOps,
applyEconomyEffects,
} from './economy';
// ── industry ───────────────────────────────────────────────────────────────────
export {
validateIndustryOps,
isIndustryType,
applyIndustryEffects,
} from './industry';
// ── nuclear ────────────────────────────────────────────────────────────────────
export {
validateNuclearOps,
applyNuclearOps,
} from './nuclear';
// ── leaders ────────────────────────────────────────────────────────────────────
export {
validateLeaderOps,
} from './leaders';
// ── ideology ───────────────────────────────────────────────────────────────────
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';
// ── contested (frontlines) ───────────────────────────────────────────────────
export {
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from './contested';
+212
View File
@@ -0,0 +1,212 @@
// The INDUSTRY ability (v0.36, #59) as a modular LLM-ability (v0.40.0, #67).
// The per-region economy-boost lever: each project is a building in one
// region (`{ regionKey, type }`, type one of oil / iron / food / industry).
// `GameState.industry` is a PER-REGION SET — a region key that is not a valid
// manifest key is dropped; a region that already hosts a project is untouched
// (the first declared type wins — re-declaring is a no-op). Every committed
// turn each active project grants its region's SOLE owner a small,
// deterministic economy boost (`INDUSTRY_ECONOMY_BONUS`, plus one extra point
// when the region's `industry` resource value is ≥ 0.5 — from the synthesized
// resource manifest; without it just the base) — clamped to 0–100, mirrored
// to every region the owner holds, and re-derived like the economy/hybrid
// effects. A project on a region with 0 or 2+ owners yields no boost but is
// kept. Sole ownership is untouched (the layer moves stats, never territory).
// All bodies are moved VERBATIM from the pre-refactor engine.ts.
import { clampStat, deriveNationStats } from '../../shared/protocol';
import type { GameState, IndustryType, RegionResources } from '../../shared/types';
import { INDUSTRY_TYPES } from '../../shared/types';
import type { IndustryOps } from '../../shared/protocol';
import { str } from './shared';
import type { Ability } from './types';
const VALID_INDUSTRY_TYPES = new Set<string>(INDUSTRY_TYPES);
/** True when `t` is one of the four known industry project types (v0.36, #59). */
export function isIndustryType(t: unknown): t is IndustryType {
return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t);
}
/**
* (v0.36, #59) Validate the raw `industry` object of a turn into a safe
* {@link IndustryOps}. Defensive like the rest of the parser — never throws.
* `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a
* known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a
* missing/malformed field are dropped. The region key is kept raw here (like
* the hybrid / economy refs) — whether it is a *valid* region key, and whether
* its region ends the turn with a sole owner, is checked at apply time against
* the manifest and the final ownership (a project on a region with no sole
* owner yields no boost but is kept). A missing / malformed top-level object
* yields `{ projects: [] }`.
*/
export function validateIndustryOps(raw: unknown): IndustryOps {
const out: IndustryOps = { projects: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.projects)) {
for (const e of o.projects) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const regionKey = str(p.regionKey, 120);
if (!regionKey || !isIndustryType(p.type)) continue;
out.projects.push({ regionKey, type: p.type });
}
}
return out;
}
/**
* (v0.36, #59) The BASE per-turn economy boost one industry project grants its
* region's sole owner. A project's actual boost is this base plus one extra
* point when the region's `industry` resource value is at least 0.5 (a region
* with a strong industry profile yields a bigger return). The base value is
* deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} /
* {@link EMBARGO_ECONOMY_PENALTY}.
*/
const INDUSTRY_ECONOMY_BONUS = 1;
/**
* (v0.36, #59) The per-turn economy boost for ONE industry project sitting in
* `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the
* region's `industry` resource value (from `resourceOf`) is at least 0.5.
* Without the resource manifest (or for a region with no profile) the boost is
* the base value. Pure + deterministic.
*/
function industryBoost(
regionKey: string,
resourceOf?: (key: string) => RegionResources | undefined,
): number {
const r = resourceOf?.(regionKey);
return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0);
}
/**
* (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a
* PER-REGION SET: each op names a region key + a project type. A region key
* that is not a valid manifest key (`validKeys`, when supplied) is dropped; a
* region that already hosts a project is untouched (the first declared type
* wins — re-declaring on the same region is a no-op, the type is never
* replaced). Otherwise the project is appended. Mutates `next` in place.
* Nothing here throws.
*/
function applyIndustryOps(
next: GameState,
ops: IndustryOps,
validKeys?: ReadonlySet<string>,
): void {
if (ops.projects.length === 0) return;
const has = (key: string) => next.industry.some((p) => p.regionKey === key);
for (const p of ops.projects) {
if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop
if (has(p.regionKey)) continue; // a region hosts at most one project
next.industry.push({ regionKey: p.regionKey, type: p.type });
}
}
/**
* (v0.36, #59) The per-turn economic effect of the industry layer, applied
* EVERY committed turn (not just to this turn's adds): each active project
* grants its region's SOLE owner a small, deterministic economy boost (see
* {@link industryBoost}) — clamped to 0–100, mirrored to every region the
* owner holds (`regionStats`), and each affected nation's derived stats are
* re-derived afterwards (the same 0-region / no-entry guards as
* `applyEconomyEffects`). A project whose region ends the turn with NO owner,
* or 2+ owners (released to no man's land / corrupt), yields no boost but is
* kept in the list. Sole ownership is untouched. No-op when there are no
* active projects. Never throws.
*/
export function applyIndustryEffects(
next: GameState,
resourceOf?: (key: string) => RegionResources | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
): void {
if (next.industry.length === 0) return;
// Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to
// nothing and yields no boost).
const ownerOf = new Map<string, string>();
{
const owners = new Map<string, string[]>();
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
const list = owners.get(key);
if (list) list.push(n.id);
else owners.set(key, [n.id]);
}
}
for (const [key, list] of owners) {
if (list.length === 1) ownerOf.set(key, list[0]);
}
}
// nation id -> accumulated economy boost (a nation owning several project
// regions gets each of their boosts).
const delta = new Map<string, number>();
for (const p of next.industry) {
const owner = ownerOf.get(p.regionKey);
if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept)
const n = next.nations[owner];
if (!n) continue; // a nation removed this turn (a merge): the boost is dropped
delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf));
}
const affected = new Set<string>();
for (const [id, d] of delta) {
const n = next.nations[id];
if (!n) continue;
n.stats.economy = clampStat(n.stats.economy + d);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs.economy = clampStat(rs.economy + d);
}
affected.add(id);
}
if (affected.size > 0) {
for (const id of affected) {
const n = next.nations[id];
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
}
}
export const industry: Ability<'industry'> = {
field: 'industry',
default: () => ({ projects: [] }),
validate: (raw) => validateIndustryOps(raw),
apply: (state, data, ctx) => {
// Placed after the economy block (both move stats, and the industry boost
// should compose with the trade/embargo effects of the same turn) and
// before markers / nationOps. Order:
// (1) APPLY OPS: this turn's `industry.projects` are added to
// `GameState.industry` as a per-region SET — a region key that is not
// a valid manifest key is dropped, and a region that already hosts a
// project is untouched (the first declared type wins; re-declaring is
// a no-op).
// (2) PER-TURN EFFECT: the FULL active project list (not just this
// turn's adds) each grants their region's SOLE owner a small,
// deterministic economy boost scaled by the region's `industry`
// resource value — clamped, mirrored to every region the owner holds,
// and re-derived like the economy/hybrid effects. A project whose
// region ends the turn with no owner (or 2+ owners) yields no boost
// but is KEPT (the region may be re-claimed later). Sole ownership is
// untouched (the layer moves stats, never territory).
applyIndustryOps(state, data, ctx.validRegionKeys);
applyIndustryEffects(state, ctx.resourceOf, ctx.geoOf);
},
schemaLine: () =>
' "industry": {"projects":[{"regionKey":"<region key>","type":"<oil|iron|food|industry>"}]},',
constraint: () =>
'- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"<region key>","type":"<oil|iron|food|industry>"} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".',
isMeaningful: (data) => data.projects.length > 0,
stateFields: ['industry'],
stub: () => ({ projects: [] }),
payload: (ctx) => {
const out: Record<string, unknown> = {};
if (ctx.nationResources !== undefined) out.resources = ctx.nationResources;
if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects;
return out;
},
};
+156
View File
@@ -0,0 +1,156 @@
// The NAMED-LEADERS ability (v0.38, #61) as a modular LLM-ability (v0.40.0,
// #67). The model may change a nation's head of state: an `assassination`
// (a dramatic, destabilizing blow — a −15 stability dip, a "military" event,
// only valid when the target already has a leader), a `death` (a natural-
// causes succession — a quieter −3 dip, a "political" event, also only valid
// for a nation that has a leader), or an `appointed` change (sets or creates
// a leader and is always valid — a neutral 0 effect, a "political" event).
// On a valid change the target's `leader` is set, the DETERMINISTIC stability
// dip (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
// (clamped) and mirrored to every region the nation owns (`regionStats`), the
// nation's derived stats are re-derived afterwards (the same 0-region /
// no-entry guards), and a narration event is appended. The replacement never
// moves territory (sole ownership is untouched). All bodies are moved
// VERBATIM from the pre-refactor engine.ts.
import { deriveNationStats, makeEvent } from '../../shared/protocol';
import { clampStat, LEADER_STABILITY_EFFECT } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import type { LeaderCause, LeaderChange } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (v0.38, #61) Validate the raw `leaders` object of a turn into a safe
* `LeaderChange[]`. Defensive like the rest of the parser — never throws.
* Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change
* needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty
* `name` (the new leader's name, capped at 120 chars), and a `cause` that is
* one of the three known literals (assassination / death / appointed). Refs
* stay raw here (resolved against the real state at apply time, like the wars
* / hybrid / nuclear ops); a leaderless assassination/death is dropped at
* apply time, never thrown.
*/
export function validateLeaderOps(raw: unknown): LeaderChange[] {
const out: LeaderChange[] = [];
const list = Array.isArray(raw)
? raw
: raw && typeof raw === 'object'
? (raw as Record<string, unknown>).changes
: undefined;
if (!Array.isArray(list)) return out;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const target = str(s.target, 120);
const name = str(s.name, 120);
if (!target || !name) continue;
const cause = typeof s.cause === 'string' ? s.cause : '';
if (cause === 'assassination' || cause === 'death' || cause === 'appointed') {
out.push({ target, name, cause: cause as LeaderCause });
}
}
return out;
}
/**
* (v0.38, #61) Apply a single leader change to `next` (in place). The target
* nation is resolved by the shared resolver (id first, then a case-insensitive
* unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE
* then applies: an `assassination` or `death` requires the target to already
* have a leader (a leaderless target is dropped); an `appointed` change is
* always valid (it creates the first leader). On a valid change the target's
* `leader` is set to the new name and a DETERMINISTIC stability dip
* (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability`
* (clamped 0–100) and mirrored to every region the nation owns
* (`regionStats`); the nation's derived stats are re-derived afterwards (the
* same 0-region / no-entry guards as the hybrid/economy/industry effects, so a
* 0-region nation keeps its directly-mutated value). Finally a narration event
* is appended: a dramatic `military` event for an assassination, a `political`
* succession event for a death or an appointment. The replacement never moves
* territory (sole ownership is untouched).
*/
function applyLeaderChange(
next: GameState,
op: LeaderChange,
geoOf?: (key: string) => { area: number; population: number } | undefined,
actingTurn?: number,
): void {
const turn = actingTurn ?? next.turn;
const id = resolveNationRef(next, op.target);
if (id === null) return; // unknown / ambiguous target: drop, never throw
const n = next.nations[id];
// GATE: assassination / death require an existing leader; an appointment is
// always valid (it creates the first leader).
if (op.cause !== 'appointed' && !n.leader) return;
const prevName = n.leader?.name;
n.leader = { name: op.name };
const delta = LEADER_STABILITY_EFFECT[op.cause];
if (delta !== 0) {
n.stats.stability = clampStat(n.stats.stability + delta);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs.stability = clampStat(rs.stability + delta);
}
// Re-derive the nation's aggregate stats (the main recompute ran earlier
// this turn, so without this the dip would be washed out next turn).
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
const title =
op.cause === 'assassination'
? `${prevName ?? 'The leader'} of ${n.name} is assassinated`
: op.cause === 'death'
? `${prevName ?? 'The leader'} of ${n.name} dies`
: `${n.name} appoints ${op.name}`;
const text =
op.cause === 'assassination'
? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.`
: op.cause === 'death'
? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.`
: `${n.name} has appointed ${op.name} as its new leader.`;
next.events.push(
makeEvent(
{
nation: n.id,
type: op.cause === 'assassination' ? 'military' : 'political',
title,
text,
outcome: 'success',
},
turn,
next.date,
),
);
}
export const leaders: Ability<'leaders'> = {
field: 'leaders',
default: () => ({ changes: [] }),
// The low-level validator returns a bare list; the field's shape is the
// `{ changes }` object (mirrors the historical `result.leaders = { changes: ... }`).
validate: (raw) => ({ changes: validateLeaderOps(raw) }),
apply: (state, data, ctx) => {
// Placed after the nuclear block (both are the "heavier" levers) and
// before the markers block (markers recompute their owner from the FINAL
// post-claim ownership; leaders only touch stats, so ordering w.r.t.
// markers is immaterial, but keeping the levers grouped is clearer). Each
// change sets the target's leader, applies a deterministic stability dip
// by cause, and appends a narration event. A leaderless assassination/
// death is dropped.
for (const op of data.changes) applyLeaderChange(state, op, ctx.geoOf, ctx.actingTurn);
},
schemaLine: () =>
' "leaders": {"changes":[{"target":"<nation id or name>","name":"<new leader name>","cause":"<assassination|death|appointed>"}]},',
constraint: () =>
'- 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).',
isMeaningful: (data) => data.changes.length > 0,
stub: () => ({ changes: [] }),
payload: () => ({}),
};
+574
View File
@@ -0,0 +1,574 @@
// 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
// ---------------------------------------------------------------------------
// 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: () => ({}),
};
+336
View File
@@ -0,0 +1,336 @@
// The NUCLEAR / WMD ability (v0.37, #60) as a modular LLM-ability (v0.40.0, #67).
// The model may name nations to ARM with the nuclear flag (`nuclear.arm`) and
// fire nuclear strikes (`nuclear.strikes`, from a capable nation at a nation or
// a region). The deterministic effects (devastating the target, MAD retaliation,
// ending the target's wars, forcing relations hostile, releasing struck regions
// to no man's land) are all applied here. The apply bodies are moved VERBATIM
// from the pre-refactor engine.ts; `NUCLEAR_COLLAPSE` / `NUCLEAR_RETALIATION` /
// `isNuclearCapable` / `makeEvent` / `deriveNationStats` come from shared/protocol.
import {
clampStat,
deriveNationStats,
isNuclearCapable,
makeEvent,
NUCLEAR_COLLAPSE,
NUCLEAR_RETALIATION,
type NuclearOps,
type NuclearStatKey,
} from '../../shared/protocol';
import type { GameState, Nation } from '../../shared/types';
import { activeWarPairs } from './apply-helpers';
import { resolveNationRef, resolveRegionKey, str } from './shared';
import type { Ability } from './types';
const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability'];
/**
* (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe
* {@link NuclearOps}. Defensive like the rest of the parser — never throws.
* `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is
* resolved (id or name) at APPLY time, so an unknown ref is dropped there, not
* here. `strikes`: each needs a non-empty `from` (the striking nation ref) and
* a non-empty `target` (a nation ref OR a region key), each capped at 120
* chars; a self-target (`from` and `target` the same ref) is dropped here. A
* missing / malformed top-level object yields `{ arm: [], strikes: [] }`.
*/
export function validateNuclearOps(raw: unknown): NuclearOps {
const out: NuclearOps = { arm: [], strikes: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.arm)) {
for (const e of o.arm) {
const s = typeof e === 'string' ? e.trim() : '';
if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120));
}
}
if (Array.isArray(o.strikes)) {
for (const e of o.strikes) {
if (!e || typeof e !== 'object') continue;
const s = e as Record<string, unknown>;
const from = str(s.from, 120);
const target = str(s.target, 120);
if (!from || !target) continue;
if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop
out.strikes.push({ from, target });
}
}
return out;
}
/**
* Devastate a nation's stats by a delta profile (collapse / retaliation): each
* of the three nuclear stat keys is shifted by the given delta (clamped) on
* BOTH the nation and every region it owns. Never throws.
*/
function collapseNationStats(
next: GameState,
nationId: string,
collapse: Record<NuclearStatKey, number>,
): void {
const n = next.nations[nationId];
if (!n) return;
for (const k of NUCLEAR_STATS) {
const d = collapse[k];
n.stats[k] = clampStat(n.stats[k] + d);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (rs) rs[k] = clampStat(rs[k] + d);
}
}
}
/**
* Release a region key to no man's land, reusing the turn's release semantics:
* the key leaves its SOLE owner's territory, is marked in `GameState.unowned`,
* and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an
* already-unowned key) is left untouched. Never throws.
*/
function releaseRegionToNoMansLand(next: GameState, key: string): void {
if (next.unowned.includes(key)) return; // already no man's land
const owners = Object.values(next.nations).filter((n) => n.territory.includes(key));
if (owners.length !== 1) return; // 0 or 2+ owners: not releasable
next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter(
(k) => k !== key,
);
next.unowned.push(key);
delete next.regionStats[key];
}
/**
* End every ACTIVE war whose sides include both `a` and `b` (a nuclear
* capitulation), then downgrade the `war` relation of every pair that is no
* longer covered by an active war back to `hostile` (the war → relation sync).
* Capturing the active pairs immediately BEFORE the end keeps this correct for
* a war declared and ended in the SAME turn (where the start-of-turn `prev`
* that `syncWarRelations` uses would not contain it). Never throws.
*/
function endWarsBetween(next: GameState, a: string, b: string): void {
const before = activeWarPairs(next.wars);
let ended = false;
for (const w of next.wars) {
if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) {
w.status = 'capitulation';
ended = true;
}
}
if (!ended) return;
const after = activeWarPairs(next.wars);
for (const key of before) {
if (after.has(key)) continue; // still covered by another active war
const [x, y] = key.split('\u0000');
const nx = next.nations[x];
const ny = next.nations[y];
if (!nx || !ny) continue;
if (nx.relations[y] === 'war' || ny.relations[x] === 'war') {
nx.relations[y] = 'hostile';
ny.relations[x] = 'hostile';
}
}
}
/**
* Force the relation between two nations to `hostile` in BOTH directions —
* the aftermath of a MAD exchange. A `war` relation is never downgraded (the
* war entity owns it while active — e.g. a region strike that devastates one
* warring side without ending the war). Never throws.
*/
function forceHostile(next: GameState, a: string, b: string): void {
const na = next.nations[a];
const nb = next.nations[b];
if (!na || !nb) return;
if (na.relations[b] !== 'war') na.relations[b] = 'hostile';
if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile';
}
/**
* Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in
* place and never throwing. See the section comment above for the full
* contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the
* "owns a rocket" half of nuclear capability (a rocket marker placed this very
* turn makes its owner capable only from the next turn, while a nation that
* already owns one this turn is capable now).
*/
export function applyNuclearOps(
next: GameState,
ops: NuclearOps,
state: GameState,
geoOf?: (key: string) => { area: number; population: number } | undefined,
validKeys?: ReadonlySet<string>,
regionNameOf?: (key: string) => string | undefined,
actingTurn?: number,
): void {
if (ops.arm.length === 0 && ops.strikes.length === 0) return;
const turn = actingTurn ?? next.turn;
const markers = state.markers ?? [];
const capable = (id: string): boolean => {
const n = next.nations[id];
return n ? isNuclearCapable(n, markers) : false;
};
// (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped.
for (const ref of ops.arm) {
const id = resolveNationRef(next, ref);
if (id === null) continue;
const n = next.nations[id];
if (!n) continue;
n.nuclear = true;
}
const affected = new Set<string>();
const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key;
// (2) STRIKES: each validated independently against the post-arm state.
for (const strike of ops.strikes) {
const fromId = resolveNationRef(next, strike.from);
if (fromId === null) continue; // unknown / ambiguous striker: drop
const from = next.nations[fromId];
if (!from) continue;
if (!capable(fromId)) continue; // a non-capable nation cannot strike
if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop
// Resolve the target: a NATION (a ref resolving to a distinct nation) or a
// REGION (a region key / name with a sole owner that is not the striker).
let target:
| { kind: 'nation'; nation: Nation }
| { kind: 'region'; key: string; owner: Nation }
| null = null;
const tid = resolveNationRef(next, strike.target);
if (tid !== null) {
const tn = next.nations[tid];
if (tn) target = { kind: 'nation', nation: tn };
} else {
const rk = validKeys
? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined))
: (strike.target.trim() || null);
if (rk !== null) {
const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk));
if (owners.length === 1 && owners[0]!.id !== fromId) {
target = { kind: 'region', key: rk, owner: owners[0]! };
}
// a 0/2+-owner region, or a region the striker owns (self), is dropped
}
}
if (target === null) continue; // unresolvable target: drop
if (target.kind === 'nation') {
// Devastate the target nation.
collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE);
affected.add(target.nation.id);
// A strike on a nation at war with the striker ends that war in
// capitulation (the relation then falls back to hostile).
endWarsBetween(next, fromId, target.nation.id);
// MAD: a nuclear-capable target strikes back at half strength and the
// two nations end the exchange at least hostile.
if (capable(target.nation.id)) {
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
affected.add(fromId);
forceHostile(next, fromId, target.nation.id);
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: 'MAD retaliation',
text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`,
outcome: 'failure',
},
turn,
next.date,
),
);
}
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: `Nuclear strike on ${target.nation.name}`,
text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`,
outcome: 'success',
},
turn,
next.date,
),
);
} else {
// Devastate the region: release it to no man's land, then devastate its
// former owner.
const owner = target.owner;
releaseRegionToNoMansLand(next, target.key);
collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE);
affected.add(owner.id);
// MAD: a nuclear-capable owner strikes back at half strength and the
// two nations end the exchange at least hostile.
if (capable(owner.id)) {
collapseNationStats(next, fromId, NUCLEAR_RETALIATION);
affected.add(fromId);
forceHostile(next, fromId, owner.id);
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: 'MAD retaliation',
text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay(
target.key,
)}; mutual assured destruction devastates ${from.name} in turn.`,
outcome: 'failure',
},
turn,
next.date,
),
);
}
next.events.push(
makeEvent(
{
nation: fromId,
type: 'military',
title: `Nuclear strike on ${regionDisplay(target.key)}`,
text: `${from.name} obliterates ${regionDisplay(
target.key,
)} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`,
outcome: 'success',
},
turn,
next.date,
),
);
}
}
// (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region /
// no-entry guards as the main recompute), so the collapse persists.
for (const id of affected) {
const n = next.nations[id];
if (!n) continue;
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
}
export const nuclear: Ability<'nuclear'> = {
field: 'nuclear',
default: () => ({ arm: [], strikes: [] }),
validate: (raw) => validateNuclearOps(raw),
apply: (state, data, ctx) => {
applyNuclearOps(
state,
data,
ctx.previous,
ctx.geoOf,
ctx.validRegionKeys,
ctx.regionNameOf,
ctx.actingTurn,
);
},
schemaLine: () =>
' "nuclear": {"arm":["<nation id or name>"],"strikes":[{"from":"<nuclear-capable nation id or name>","target":"<nation id or name, or region key>"}]},',
constraint: () =>
'- 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".',
isMeaningful: (data) => data.arm.length > 0 || data.strikes.length > 0,
stub: () => ({ arm: [], strikes: [] }),
payload: () => ({}),
};
+470
View File
@@ -0,0 +1,470 @@
// The ordered LLM-ability registry: contract + golden-prompt tests (v0.40.0, #67).
//
// These tests pin the refactor's two load-bearing guarantees:
//
// 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user
// 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 12 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
// and every uniform loop still runs).
//
// Regenerate the goldens after an INTENTIONAL prompt change with:
// REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts
// (the goldens are then rewritten from the current engine output; review the
// diff and commit). In a normal run (no env) the tests assert byte-equality.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
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,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from '../engine';
import { createPresetStore } from '../presets';
import { ABILITIES } from './index';
import type { Ability, AbilityField } from './types';
import type {
Embargo,
GameState,
IndustryProject,
Nation,
NationStats,
RegionResources,
RelationStatus,
RoundItem,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
const here = path.dirname(fileURLToPath(import.meta.url));
const goldensDir = path.join(here, 'goldens');
const REGEN = process.env.REGEN_GOLDENS === '1';
// --- The preset the system-prompt goldens were captured with. ---
const root = path.resolve(here, '..', '..');
const store = createPresetStore(path.join(root, 'presets'));
const preset = store.get('wwii-1939');
if (!preset) throw new Error('wwii-1939 preset not found');
// --- The small, fully-controlled deterministic state that exercises EVERY
// --- optional branch of buildUserPrompt (re-embedded from the P1
// --- golden-capture so the committed goldens can be regenerated). ---
function nation(
id: string,
name: string,
stats: NationStats,
territory: string[],
extra: Partial<Nation> = {},
): Nation {
return { id, name, type: 'major', stats, territory, relations: {}, ...extra };
}
const s = (m: number, e: number, st: number, p: number): NationStats => ({
military: m,
economy: e,
stability: st,
population: p,
});
const a1 = nation('a1', 'Alpha', s(60, 55, 70, 80), ['a-r1', 'a-r2'], {
nuclear: true,
leader: { name: 'Alpha Leader' },
ideology: 'democracy',
relations: { b2: 'ally', c3: 'rival' },
});
const b2 = nation('b2', 'Bravo', s(75, 40, 20, 30), ['b-r1', 'b-r2'], {
ideology: 'fascism',
relations: { a1: 'ally' },
});
const c3 = nation('c3', 'Charlie', s(30, 35, 80, 20), ['c-r1'], {
relations: { a1: 'rival' },
});
const state: GameState = {
id: 'golden-0000-0000-0000-0000-000000000000',
presetId: 'wwii-1939',
presetTitle: preset.title,
playerNationId: 'a1',
turn: 4,
date: '1940-02-14',
nations: { a1, b2, c3 },
events: [],
pending: [],
version: 9,
unowned: ['x-unowned'],
pendingNationOps: [],
markers: [],
regionStats: {
'a-r1': s(60, 55, 70, 80),
'a-r2': s(58, 53, 72, 78),
'b-r1': s(75, 40, 20, 30),
'b-r2': s(70, 42, 22, 28),
'c-r1': s(30, 35, 80, 20),
},
wars: [],
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// --- buildUserPrompt arguments: every optional branch exercised. ---
const items: RoundItem[] = [
{ kind: 'action', text: 'Rearm the northern border' },
{ kind: 'diplomacy', to: 'b2', text: 'We can still talk terms, no?' },
];
const regionReference: Record<string, string> = {
'a-r1': 'Alpha North (US)',
'a-r2': 'Alpha South (US)',
'b-r1': 'Bravo Plain (RU)',
'b-r2': 'Bravo Coast (RU)',
'c-r1': 'Charlie Isle (FR)',
'x-unowned': 'X Released (UN)',
};
const frontier: string[] = ['b-r1']; // owned by b2 (a non-player) → frontierMilitary
const noMansLand: string[] = ['x-unowned'];
const markers: MarkerInfo[] = [
{ id: 'mk0001', type: 'gathering', name: 'Bravo Rally', regionKey: 'b-r1', owner: 'b2' },
{ id: 'mk0002', type: 'rocket', name: 'Charlie Silo', regionKey: 'c-r1', owner: 'c3' },
{ id: 'mk0003', type: 'capital', name: 'Alpha Cap', regionKey: 'a-r1', owner: 'a1' },
];
const wars: WarInfo[] = [
{ id: 'w1', sides: ['a1', 'b2'], aims: ['control the border'] },
{ id: 'w2', sides: ['b2', 'c3'] },
];
const relations: Record<string, RelationStatus> = { b2: 'ally', c3: 'rival' };
const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }];
const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }];
const nationResources: Record<string, RegionResources> = {
a1: { oil: 0.5, iron: 0.2, food: 0.3, industry: 0.8 },
b2: { oil: 0.1, iron: 0.9, food: 0.1, industry: 0.2 },
c3: { oil: 0.2, iron: 0.3, food: 0.7, industry: 0.4 },
};
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
const seaZones: SeaZoneClaim[] = [];
// (v0.44.0, #66) a non-empty contested sample so the golden's user payload
// includes the `contested` key (region key + party NAMES, resolved from the
// state's nations). `b-r1` is owned by b2; the parties are a1 + b2.
const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }];
/**
* Assert the current prompt output is byte-identical to the committed golden —
* or, under REGEN_GOLDENS=1, rewrite the golden from the current output (a
* manual regeneration step; the test then passes without asserting).
*/
function checkGolden(name: string, current: string): void {
const file = path.join(goldensDir, name);
if (REGEN) {
mkdirSync(goldensDir, { recursive: true });
writeFileSync(file, current, 'utf8');
return;
}
const expected = readFileSync(file, 'utf8');
expect(current, `golden ${name} mismatch`).toBe(expected);
}
/** The canonical (default / no-op) value of EVERY TurnResult field. */
function fullDefaultTurn(): TurnResult {
return {
date: '1939-09-01',
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: [] },
contested: { open: [], resolve: [] },
markers: { add: [], remove: [] },
};
}
/** A "meaningful" (non-default) value for each ability field. */
function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
switch (field) {
case 'takeovers':
return [{ nationRef: 'a1', newRegimeName: 'Alpha Regime' }];
case 'wars':
return { declare: [{ sides: ['a1', 'b2'] }], end: [] };
case 'hybrid':
return { ops: [{ type: 'spy', from: 'a1', target: 'b2' }] };
case 'economy':
return { trade: [{ a: 'a1', b: 'c3' }], embargo: [] };
case 'industry':
return { projects: [{ regionKey: 'a-r1', type: 'oil' }] };
case 'nuclear':
return { arm: ['a1'], strikes: [] };
case 'leaders':
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' }] };
case 'contested':
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
}
}
describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 12-ability order (takeovers → contested)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers',
'wars',
'hybrid',
'economy',
'industry',
'nuclear',
'leaders',
'ideology',
'disasters',
'migration',
'sea',
'contested',
]);
});
it('lists 12 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(12);
expect(new Set(fields).size).toBe(12);
const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true);
});
it('system prompt (no tool) is byte-identical to the committed golden', () => {
checkGolden('system-no-tool.txt', buildSystemPrompt(preset, false));
});
it('system prompt (tool) is byte-identical to the committed golden', () => {
checkGolden('system-tool.txt', buildSystemPrompt(preset, true));
});
it('user prompt is byte-identical to the committed golden', () => {
checkGolden(
'user.txt',
buildUserPrompt(
state,
items,
regionReference,
frontier,
noMansLand,
markers,
wars,
relations,
trades,
embargoes,
nationResources,
industryProjects,
nuclear,
seaZones,
contested,
),
);
});
it("each ability's default() equals the canonical TurnResult default for its field", () => {
const all = fullDefaultTurn();
for (const a of ABILITIES) {
expect(a.default(), `default() for ${a.field}`).toEqual(all[a.field]);
}
});
it('default() is a FRESH value on every call (never a shared reference)', () => {
for (const a of ABILITIES) {
expect(a.default(), `${a.field} default should be a fresh object`).not.toBe(a.default());
}
});
it('default() is never meaningful; a populated value always is', () => {
for (const a of ABILITIES) {
expect(a.isMeaningful(a.default()), `${a.field} default should not be meaningful`).toBe(false);
expect(a.isMeaningful(meaningfulFor(a.field)), `${a.field} populated should be meaningful`).toBe(
true,
);
}
});
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(['contested', '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 12th ability is appended.
// This documents the extension point without widening the closed
// AbilityField union.
interface Loopable {
field: string;
schemaLine(): string;
constraint(): string;
}
const dummy: Loopable = {
field: 'custom',
schemaLine: () => ' "custom": [],',
constraint: () => '- A custom ability.',
};
const extended: readonly (Ability<AbilityField> | Loopable)[] = [...ABILITIES, dummy];
expect(extended.map((a) => a.field)).toEqual([
...ABILITIES.map((a) => a.field),
'custom',
]);
// every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(13);
expect(extended.map((a) => a.constraint())).toHaveLength(13);
expect(extended[12].schemaLine()).toBe(' "custom": [],');
});
it('the engine still re-exports every name its callers import from it', () => {
// Backward compatibility: server/index.ts and engine.test.ts import these
// from the engine; the re-export block must keep them all exported (a
// missing re-export would also fail typecheck — this pins it at runtime).
const reexported: Record<string, unknown> = {
// shared primitives (./abilities/shared)
freshNationId,
freshWarId,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
// ability modules (./abilities)
isHybridOpType,
isIndustryType,
activeWarPairs,
atWar,
syncWarRelations,
HYBRID_STAT_EFFECT,
shiftRelationTowardHostile,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateNuclearOps,
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,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
};
for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined();
}
});
});
+496
View File
@@ -0,0 +1,496 @@
// 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,
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
// ---------------------------------------------------------------------------
// 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 ?? [] }),
};
+109
View File
@@ -0,0 +1,109 @@
// Shared reference-resolution / id-generation primitives used across the
// modular LLM abilities (and re-exported by the core engine). Living here —
// and in `./types.ts` rather than in `engine.ts` — breaks the engine <->
// ability import cycle: the ability modules import from here, `engine.ts`
// imports + re-exports them for the existing callers (`server/index.ts`, the
// unit tests), and no ability imports the engine. Pure and dependency-free.
import type { GameState, NationStats } from '../../shared/types';
/**
* Resolve a nation reference (the `nationRef` of a NationOp, or the `target` /
* `from` / `sides` of an ability op — the LLM may write either the nation's
* stable `id` or its human-readable `name`) to a concrete nation id: an exact
* id match wins, then a unique case-insensitive name match. Returns `null` when
* the reference is empty, unknown, or ambiguous (two or more nations share that
* name) — every caller treats `null` as "no-op" and nothing here ever throws.
*/
export function resolveNationRef(state: GameState, ref: string): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (state.nations[r]) return r; // exact id first
const matches = Object.values(state.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
}
/**
* (v0.29, #46 PR 2) Resolve a **region** reference (a region op's `region` /
* `regionKey` — the LLM may write either the region's stable **key** or its
* human-readable **name**) to a concrete region key: an exact key match wins,
* then a unique case-insensitive name match (via `nameOf`). Returns `null` when
* the reference is empty, unknown, or ambiguous (two or more regions share that
* name) — every caller treats `null` as "drop this change" and nothing here
* ever throws. Mirrors {@link resolveNationRef}. `validKeys` is the set of all
* region keys and `nameOf` maps a key to its human name (both supplied by
* server/regions.ts).
*/
export function resolveRegionKey(
ref: string,
validKeys: ReadonlySet<string>,
nameOf: (key: string) => string | undefined,
): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (validKeys.has(r)) return r; // exact key first
const lower = r.toLowerCase();
let match: string | null = null;
for (const k of validKeys) {
const name = nameOf(k);
if (name && name.trim().toLowerCase() === lower) {
if (match !== null) return null; // ambiguous: two or more regions share the name
match = k;
}
}
return match;
}
/**
* Generate a fresh, unused nation id for a newly created nation (split parts,
* v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds each id it
* creates to `taken` as it goes, so ids stay unique within an op and are never
* reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* Generate a fresh, unused war id for a newly declared {@link War}
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id it
* creates to `taken` as it goes, so ids stay unique within a turn and are never
* reused from an ended war.
*/
export function freshWarId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `w${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT a
* copy of the parent's stats (a small rump state is not the parent's military
* power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
/**
* Coerce an unknown LLM JSON value to a trimmed, non-empty string, or `null`
* when it is not a non-empty string. An optional `max` caps the length (used
* for the 200-char caps on nationOp fields). Shared by every defensive
* validator (engine + the ability modules). Never throws.
*/
export 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;
}
+100
View File
@@ -0,0 +1,100 @@
// The TAKEOVER ability (v0.32.0, #54) as a modular LLM-ability (v0.40.0, #67).
// The model may overthrow a low-stability nation hosting a "gathering" marker
// (a coup / revolution) — an AUTOMATIC, non-military regime change that RENAMES
// the nation to its new regime (the stable id, territory, relations, stats and
// playerNationId all survive). The validator and the apply body are moved
// VERBATIM from the pre-refactor engine.ts; the user-payload `takeoverRisks`
// hint (v0.30, #50 — the bounded soft-lever nudge listing the nations ripe for
// a takeover) moves here too, since it is this ability's own prompt data.
import type { GameState } from '../../shared/types';
import type { Takeover } from '../../shared/protocol';
import { resolveNationRef, str } from './shared';
import type { Ability } from './types';
/**
* (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.
*/
export function validateTakeovers(raw: unknown): Takeover[] {
if (!Array.isArray(raw)) return [];
const out: Takeover[] = [];
for (const e of raw) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
const nationRef = str(o.nationRef, 200);
const newRegimeName = str(o.newRegimeName, 200);
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
}
return out;
}
/**
* (v0.32.0, #54) Apply a takeover to `next` (in place). For each takeover:
* 1. Resolve the target nation via `resolveNationRef` (id first, then
* case-insensitive unique name). Skip (no-op) if `null` — an
* unknown or ambiguous ref is silently dropped, never thrown.
* 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
* the display name changes — the stable id, territory, relations
* (keyed by id), stats, and `playerNationId` (which references the
* id, not the name) are all preserved. The player keeps leading an
* overthrown nation under its new regime.
*/
export function applyTakeover(next: GameState, t: Takeover): void {
const id = resolveNationRef(next, t.nationRef);
if (id === null) return; // unknown / ambiguous ref: no-op
const n = next.nations[id];
if (!n) return; // defensive: id resolved but nation missing (corrupt state)
n.name = t.newRegimeName;
}
export const takeovers: Ability<'takeovers'> = {
field: 'takeovers',
default: () => [],
validate: (raw) => validateTakeovers(raw),
apply: (state, data) => {
// Applied after the per-region recompute (the rename doesn't depend on
// territory or stats, so it is order-independent with respect to those
// steps) and before the markers block (a marker's owner is a nation id,
// not a name, so the rename does not affect the owner recompute).
for (const t of data) applyTakeover(state, t);
},
schemaLine: () =>
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
constraint: () =>
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
isMeaningful: (data) => data.length > 0,
stub: () => [],
payload: (ctx) => {
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
// AND that currently have a "gathering" marker on one of their regions are
// the ones ripe for a NON-military takeover (a coup / revolution / faction
// seizure) — the model can act on them this turn. One line per matching
// nation, emitted only when there is at least one; the rest of the payload
// is left untouched. A soft nudge only — there is no hard gate behind it.
let takeoverRisks: string[] | undefined;
if (ctx.markers !== undefined && ctx.markers.length > 0) {
const risks = new Set<string>();
for (const m of ctx.markers) {
if (m.type !== 'gathering') continue;
const ownerId = m.owner;
if (!ownerId) continue;
const n = ctx.state.nations[ownerId];
if (n && n.stats.stability < 30) risks.add(n.id);
}
if (risks.size > 0) {
takeoverRisks = [...risks].map((id) => {
const n = ctx.state.nations[id];
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
});
}
}
return takeoverRisks !== undefined ? { takeoverRisks } : {};
},
};
+181
View File
@@ -0,0 +1,181 @@
// The shared type surface for the modular LLM-ability system (v0.40.0, #67).
// Every ability is a plain object conforming to `Ability`, listed (in its
// canonical apply / prompt / payload order) by `ABILITIES` in ./index.ts. This
// file holds only the *types* the ability modules program against — the
// concrete implementations live in the sibling one-file-per-ability modules.
// No runtime deps here (type-only imports), so it stays pure like the rest of
// the `server/abilities` package.
import type {
Embargo,
GameState,
IndustryProject,
RegionResources,
RelationStatus,
SeaZoneClaim,
TradeAgreement,
} from '../../shared/types';
import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/**
* The 12 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 twelve abilities.
*/
export type AbilityField =
| 'takeovers'
| 'wars'
| 'hybrid'
| 'economy'
| 'industry'
| 'nuclear'
| 'leaders'
| 'ideology'
| 'disasters'
| 'migration'
| 'sea'
| 'contested';
/**
* Everything a single ability's `apply` may need from the surrounding turn.
*
* `previous` is the world state BEFORE this turn: reference resolution and
* sole-ownership checks must see the state before this turn's own mutations, so
* abilities read `previous` (not the state they are mutating) to resolve refs.
* `actingTurn` is the turn number stamped onto anything the ability creates or
* ends. The region-manifest resolvers are each optional and are absent when
* their manifest is not loaded — an ability must degrade gracefully when one is
* missing (never throw).
*/
export interface AbilityCtx {
/** The world state before this turn (a read-only snapshot). */
readonly previous: GameState;
/** The turn number stamped onto anything this ability creates / ends. */
actingTurn: number;
/** All valid region keys (public/region-keys.json); undefined if not loaded. */
readonly validRegionKeys?: ReadonlySet<string>;
/** Region key -> human name; undefined if the key manifest is not loaded. */
readonly regionNameOf?: (key: string) => string | undefined;
/** Region key -> {area (km²), population}; undefined if not loaded (v0.28 #46). */
readonly geoOf?: (key: string) => { area: number; population: number } | undefined;
/** Region key -> synthesized resource profile; undefined if not loaded (v0.36 #59). */
readonly resourceOf?: (key: string) => RegionResources | undefined;
/**
* Region key -> centroid anchor {lat, lon, spread}; undefined if not loaded
* (v0.27 #44).
*/
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 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[];
}
/**
* The inputs the user-prompt builder hands to an ability's `payload` method —
* the optional buildUserPrompt parameters (the core-only ones — `items`,
* `regionReference`, `frontier`, `noMansLand`, `nuclear` — are consumed by the
* engine, not by an ability). Each ability reports only the payload keys it
* owns here; the engine merges them into the payload in the historical
* buildUserPrompt order (pinned by the user-prompt golden fixture).
*/
export interface UserPromptCtx {
readonly state: GameState;
readonly markers?: MarkerInfo[];
readonly wars?: WarInfo[];
readonly relations?: Record<string, RelationStatus>;
readonly trades?: TradeAgreement[];
readonly embargoes?: Embargo[];
readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[];
readonly seaZones?: SeaZoneClaim[];
readonly contested?: ContestedRegion[];
}
/**
* A single LLM ability — a feature the model can exercise via one named
* `TurnResult` field. Each ability is self-contained: it validates the raw
* JSON for its field, documents itself in the system prompt (a JSON schema line
* and a HARD CONSTRAINTS bullet), contributes its keys to the user payload,
* reports whether a non-default value makes the turn meaningful, declares the
* `GameState` fields it owns (for db migrations), and applies its field to the
* world. `K` pins the field so every value-bearing method operates on the
* field's exact `TurnResult[K]` type (no `any`).
*/
export interface Ability<K extends AbilityField = AbilityField> {
/** The `TurnResult` field this ability owns (its stable, unique name). */
readonly field: K;
/**
* The default (empty / no-op) value of this field — a FRESH value on every
* call (never a shared mutable reference). Used by the "unclear" fallback and
* the deterministic stub.
*/
default(): TurnResult[K];
/**
* Parse and defensively validate the raw (unknown) JSON for this field into a
* clean `TurnResult[K]`. Never throws — a missing / malformed value yields
* `default()`. NOTE: the hybrid / leaders / ideology low-level validators
* return a bare inner list, so those abilities' `validate` wraps that list
* into the field's object shape (`{ ops }` / `{ changes }`).
*/
validate(raw: unknown): TurnResult[K];
/**
* Apply this field's (validated) value to the world, MUTATING `state` in
* place. Never throws — any unresolvable reference or missing manifest
* degrades to a no-op. Abilities run in the fixed order of `ABILITIES`.
*/
apply(state: GameState, data: TurnResult[K], ctx: AbilityCtx): void;
/**
* The JSON schema line for this field in the system prompt's JSON block
* (e.g. `' "wars": {"declare":[…],"end":[…]},'`). Inserted by the engine,
* in `ABILITIES` order, between the `nationOps` block and the `markers` line.
*/
schemaLine(): string;
/**
* The HARD CONSTRAINTS bullet documenting this ability (e.g.
* `'- Wars ("wars") are named conflicts, …'`). Inserted by the engine, in
* `ABILITIES` order, between the `nationOps` bullets and the
* "- Prefer 1-4 events" bullet.
*/
constraint(): string;
/**
* Whether a non-default value of this field makes the turn "meaningful"
* (not an empty round). Mirrors the historical per-field predicate exactly.
*/
isMeaningful(data: TurnResult[K]): boolean;
/**
* 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, disasters).
*/
readonly stateFields?: readonly string[];
/**
* The deterministic stub's value for this field. Every ability returns
* `default()` (the stub never exercises an ability, so e2e is unaffected) —
* kept as a zero-arg method so the stub stays a uniform registry loop (the
* stub has no real `GameState` / `AbilityCtx` to hand one; it needs none).
*/
stub(): TurnResult[K];
/**
* The keys this ability contributes to the user prompt payload, each included
* only when it should be emitted (matching buildUserPrompt's "omit when
* undefined" behavior). The engine merges the returned entries into the
* payload at each key's exact historical position (pinned by the user-prompt
* golden).
*/
payload(ctx: UserPromptCtx): Record<string, unknown>;
}
+156
View File
@@ -0,0 +1,156 @@
// The WARS ability (v0.33.0, #56) as a modular LLM-ability (v0.40.0, #67).
// Wars are named first-class conflicts (`GameState.wars`), not just a `war`
// relation: the model declares a war (≥2 belligerents, optional aims) or ends
// one by id (armistice / capitulation). Declaring drags in each belligerent's
// mutual-defense allies; the derived `war` relation is synced from the ACTIVE
// wars by `syncWarRelations` (a war-ended pair falls back to `hostile`). The
// validator and the apply body are moved VERBATIM from the pre-refactor
// engine.ts.
import type { GameState } from '../../shared/types';
import type { TurnResult, WarDeclareOp, WarEndOp } from '../../shared/protocol';
import { freshWarId, resolveNationRef } from './shared';
import { syncWarRelations } from './apply-helpers';
import type { Ability } from './types';
/**
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
* `{ declare, end }` shape. Defensive like the rest of the parser — never
* throws. `declare`: each entry must be an object whose `sides` is an array
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
* yields at least TWO distinct sides — anything else (a one-sided or
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
* of exactly `armistice` or `capitulation` (anything else is dropped);
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
* input yields `{ declare: [], end: [] }`.
*/
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.declare)) {
for (const e of o.declare) {
if (!e || typeof e !== 'object') continue;
const d = e as Record<string, unknown>;
const sides = (Array.isArray(d.sides) ? d.sides : [])
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
.filter((s) => s !== '')
// dedupe case-insensitively? No — sides are nation ids: keep exact
// strings, drop exact duplicates only (resolution is later).
.filter((s, i, arr) => arr.indexOf(s) === i);
if (sides.length < 2) continue; // a war needs at least two belligerents
const aims = Array.isArray(d.aims)
? (d.aims as unknown[])
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
.filter((a) => a !== '')
.slice(0, 5)
: undefined;
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
}
}
if (Array.isArray(o.end)) {
for (const e of o.end) {
if (!e || typeof e !== 'object') continue;
const w = e as Record<string, unknown>;
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
if (!id) continue;
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
}
}
return out;
}
/**
* (v0.33.0, #56) Apply the turn's wars ops to the (cloned) `next` state,
* mutating it in place and never throwing. Two kinds of change, applied in
* this order against `next`:
*
* (1) DECLARE: each validated declare is resolved to concrete nation ids
* (`resolveNationRef`, so the LLM may use an id OR a unique name) and
* becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
* with the acting turn and the turn's new date. Mutually-defensive
* allies of the belligerents are DRAGGED IN: any nation that is an
* `ally` of a belligerent (per the current, post-relations relations)
* and is not already a side joins the war's `sides`. A declare with
* fewer than two DISTINCT resolved sides is dropped; a declare whose
* sides are all already contained in an existing ACTIVE war is a
* duplicate and is skipped (the existing war stands).
* (2) END: each validated end targets a war BY ID and sets its status to
* the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
* affected — an unknown id or an already-ended war is a no-op.
* (A war may be declared and ended in the SAME turn: declare runs
* first, so the just-declared id is a valid end target.)
* (3) RELATION SYNC: `syncWarRelations(prev, next)` derives the `war`
* relation from the ACTIVE wars — the load-bearing step that keeps
* the relation table honest with the war entities. `prev` is the
* state this turn started from (ctx.previous).
*
* Nothing here throws: every unresolvable / invalid op is dropped.
*/
export function applyWars(
next: GameState,
data: TurnResult['wars'],
prev: GameState,
actingTurn: number,
): void {
const takenWarIds = new Set(next.wars.map((w) => w.id));
// (1) DECLARE
for (const d of data.declare) {
const resolved: string[] = [];
for (const ref of d.sides) {
const id = resolveNationRef(next, ref);
if (id !== null && !resolved.includes(id)) resolved.push(id);
}
if (resolved.length < 2) continue; // not enough distinct belligerents
// Duplicate: an active war already contains all of these sides.
const dup = next.wars.some(
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
);
if (dup) continue;
const sides = [...resolved];
// Mutual-defense drag-in: each belligerent's allies join the war.
for (const s of resolved) {
const rels = next.nations[s]?.relations ?? {};
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
if (!sides.includes(ally)) sides.push(ally);
}
}
const id = freshWarId(takenWarIds);
takenWarIds.add(id);
next.wars.push({
id,
sides,
declaredTurn: actingTurn,
declaredDate: next.date,
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
status: 'active',
});
}
// (2) END (by id; active only)
for (const e of data.end) {
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
if (!w) continue; // unknown id / already ended: no-op
w.status = e.outcome;
}
// (3) RELATION SYNC (prev = the state this turn started from)
syncWarRelations(prev, next);
}
export const wars: Ability<'wars'> = {
field: 'wars',
default: () => ({ declare: [], end: [] }),
validate: (raw) => validateWarOps(raw),
apply: (state, data, ctx) => applyWars(state, data, ctx.previous, ctx.actingTurn),
schemaLine: () =>
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
constraint: () =>
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
isMeaningful: (data) => data.declare.length > 0 || data.end.length > 0,
stateFields: ['wars'],
stub: () => ({ declare: [], end: [] }),
payload: (ctx) => (ctx.wars !== undefined ? { wars: ctx.wars } : {}),
};
+48
View File
@@ -38,6 +38,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -222,6 +226,50 @@ describe('openDb', () => {
db.close();
});
it('(v0.39.0 #62) a legacy row saved before the nation ideology field existed loads with it absent (no recorded government)', () => {
const db = openDb(':memory:');
// makeState has no nation.ideology field — a pre-v0.39 save. The field is
// optional and not re-stamped by load, so an absent ideology stays absent.
db.save(makeState('legacy'));
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.ideology).toBeUndefined();
db.close();
});
it('(v0.39.0 #62) a set nation ideology round-trips through save/load', () => {
const db = openDb(':memory:');
const s = makeState('ideo');
s.nations.uk.ideology = 'fascism';
db.save(s);
const loaded = db.load('ideo');
expect(loaded).not.toBeNull();
expect(loaded!.nations.uk.ideology).toBe('fascism');
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('(v0.44.0 #66) a legacy row saved before the contested field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { contested?: unknown[] }).contested; // a pre-v0.44 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.contested).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:');
db.save(makeState('a'));
+15 -11
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { ABILITIES } from './abilities';
export interface Db {
save(state: GameState): void;
@@ -49,17 +50,20 @@ export function openDb(filePath: string): Db {
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
// syncWarRelations, which never force-creates a War entity).
if (state.wars === undefined) state.wars = [];
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
// legacy game has no active trades or embargoes.
if (state.trades === undefined) state.trades = [];
if (state.embargoes === undefined) state.embargoes = [];
// (v0.36, #59) Same for the industry layer: a legacy game has no
// active industry projects.
if (state.industry === undefined) state.industry = [];
// (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every
// ability-owned GameState field (wars / trades / embargoes / industry):
// a legacy game has none of them. The ABILITIES registry is the single
// source of truth for which fields the LLM abilities own — each is a
// list, so the default is `[]`, applied only-if-undefined so a saved
// value is never wiped. (Any pre-v0.33 `war` relation stays a
// bare/implicit relation — see syncWarRelations, which never
// force-creates a War entity.)
for (const a of ABILITIES) {
const fields = a.stateFields;
if (!fields) continue;
const row = state as unknown as Record<string, unknown>;
for (const f of fields) if (row[f] === undefined) row[f] = [];
}
// (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's
+246 -38
View File
@@ -47,6 +47,7 @@ import {
syncWarRelations,
validateEconomyOps,
validateHybridOps,
validateIdeologyOps,
validateIndustryOps,
validateLeaderOps,
validateMarkerOps,
@@ -132,6 +133,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -569,7 +574,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -591,7 +596,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -612,7 +617,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -631,7 +636,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1088,7 +1093,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1105,7 +1110,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1128,7 +1133,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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
@@ -1154,7 +1159,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1172,7 +1177,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1262,7 +1267,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1377,7 +1382,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1684,9 +1689,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: [] }, 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: [] }, 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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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)', () => {
@@ -1699,7 +1704,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1720,7 +1725,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
});
@@ -1741,7 +1746,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1764,7 +1769,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1797,7 +1802,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1821,7 +1826,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// no nation was renamed
@@ -1848,7 +1853,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
// neither was renamed (ambiguous)
@@ -1874,7 +1879,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!);
@@ -1900,7 +1905,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1924,7 +1929,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1946,7 +1951,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich');
@@ -1980,7 +1985,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -2004,13 +2009,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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
@@ -2037,7 +2042,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2084,7 +2089,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: {
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2109,7 +2114,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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
@@ -2125,14 +2130,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: [] }, 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: [] }, contested: { open: [], resolve: [] }, 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: [] }, 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: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2202,7 +2207,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
llm: fakeLlm([result]),
@@ -2277,6 +2282,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
}
@@ -2297,6 +2306,11 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
industry: partial.industry ?? { projects: [] },
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: [] },
contested: partial.contested ?? { open: [], resolve: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -3022,7 +3036,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped
@@ -3410,7 +3424,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3431,7 +3445,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -3452,7 +3466,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
markers: { add: [], remove: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] },
}),
).toBe(false);
});
@@ -4820,3 +4834,197 @@ describe('named leaders (v0.38.0 #61)', () => {
});
});
});
describe('ideology / form of government (v0.39.0 #62)', () => {
// `uk` starts with an ideology ('monarchy'); `de` has none. Each owns one
// region seeded to its stats (the fresh-state invariant), so a uniform dip
// leaves the derived median equal to the dipped value.
const ideoBase = (): GameState => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'monarchy' };
const de: Nation = { ...mkNation('de', NS(50, 50, 50, 50), ['d1']) };
return mkTurnState({ uk, de }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
};
describe('validateIdeologyOps', () => {
it('accepts a {changes} object and a bare array of valid changes', () => {
const fromObj = validateIdeologyOps({
changes: [
{ target: 'uk', to: 'fascism' },
{ target: 'de', to: 'democracy' },
],
});
expect(fromObj).toEqual([
{ target: 'uk', to: 'fascism' },
{ target: 'de', to: 'democracy' },
]);
expect(validateIdeologyOps([{ target: 'uk', to: 'x' }])).toEqual([{ target: 'uk', to: 'x' }]);
});
it('drops a change with a missing / empty / whitespace `to`', () => {
expect(
validateIdeologyOps({ changes: [{ target: 'uk', to: '' }, { target: 'uk', to: ' ' }] }),
).toEqual([]);
});
it('drops a change with a missing / empty / whitespace target', () => {
expect(
validateIdeologyOps({ changes: [{ target: '', to: 'fascism' }, { target: ' ', to: 'fascism' }] }),
).toEqual([]);
});
it('returns [] for undefined / non-object / non-array-changes input', () => {
expect(validateIdeologyOps(undefined)).toEqual([]);
expect(validateIdeologyOps('nope')).toEqual([]);
expect(validateIdeologyOps({ changes: 'nope' })).toEqual([]);
});
it('keeps only the valid changes of a mixed batch', () => {
const out = validateIdeologyOps({
changes: [
{ target: 'uk', to: 'Good' },
null,
{ target: '', to: 'Bad' },
{ target: 'de', to: 'Also Good' },
],
});
expect(out).toEqual([
{ target: 'uk', to: 'Good' },
{ target: 'de', to: 'Also Good' },
]);
});
it('trims both fields and caps each at 120 chars', () => {
const long = 'x'.repeat(200);
const out = validateIdeologyOps({ changes: [{ target: ' uk ', to: ` ${long} ` }] });
expect(out).toEqual([{ target: 'uk', to: 'x'.repeat(120) }]);
});
});
describe('isMeaningful — ideology', () => {
it('a turn with only an ideology change is meaningful', () => {
expect(isMeaningful(mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }))).toBe(true);
});
it('a turn with empty ideology and nothing else is not meaningful', () => {
expect(isMeaningful(mkTurnResult({ ideology: { changes: [] } }))).toBe(false);
});
});
describe('applyTurnResult — ideology', () => {
it('a valid change sets the field, dips stability −5, and adds a political event', () => {
const next = applyTurnResult(
ideoBase(),
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
);
expect(next.nations.uk.ideology).toBe('fascism');
expect(next.nations.uk.stats.stability).toBe(45); // 50 - 5
expect(next.regionStats.g1.stability).toBe(45); // mirrored to the region
expect(next.events).toHaveLength(1);
expect(next.events[0]!.type).toBe('political');
expect(next.events[0]!.title).toBe('UK\'s government shifted to fascism');
// the other nation is untouched
expect(next.nations.de.ideology).toBeUndefined();
expect(next.nations.de.stats.stability).toBe(50);
});
it('re-setting the SAME ideology still dips (idempotent for the field, not the stat)', () => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'fascism' }] } }),
);
expect(next.nations.uk.ideology).toBe('fascism'); // the same label
expect(next.nations.uk.stats.stability).toBe(45); // still a −5 dip
expect(next.events).toHaveLength(1);
expect(next.events[0]!.type).toBe('political');
});
it('a change on a multi-region nation dips every region and re-derives its stats', () => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1', 'g2']), ideology: 'monarchy' };
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50), g2: RS(50, 50, 50, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ ideology: { changes: [{ target: 'uk', to: 'dictatorship' }] } }),
);
expect(next.regionStats.g1.stability).toBe(45);
expect(next.regionStats.g2.stability).toBe(45);
expect(next.nations.uk.stats.stability).toBe(45); // re-derived from the dipped regions
});
it('resolves the target by (case-insensitive) name as well as id', () => {
const next = applyTurnResult(
ideoBase(),
mkTurnResult({ ideology: { changes: [{ target: 'UK', to: 'fascism' }] } }),
);
expect(next.nations.uk.ideology).toBe('fascism');
expect(next.nations.uk.stats.stability).toBe(45);
});
it('a 0-region nation keeps its directly-dipped stat (no re-derive to zero)', () => {
const iso: Nation = { ...mkNation('iso', NS(50, 50, 50, 50), []) };
const state = mkTurnState({ iso }, {});
const next = applyTurnResult(
state,
mkTurnResult({ ideology: { changes: [{ target: 'iso', to: 'fascism' }] } }),
);
expect(next.nations.iso.ideology).toBe('fascism');
expect(next.nations.iso.stats.stability).toBe(45); // direct dip, not re-derived
});
it('clamps the dip at 0 (a stability of 3 becomes 0, not −2)', () => {
const low: Nation = { ...mkNation('low', NS(50, 50, 3, 50), ['g1']), ideology: 'monarchy' };
const state = mkTurnState({ low }, { g1: RS(50, 50, 3, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ ideology: { changes: [{ target: 'low', to: 'fascism' }] } }),
);
expect(next.nations.low.stats.stability).toBe(0);
expect(next.regionStats.g1.stability).toBe(0);
});
it('an unknown target is dropped without throwing (no change, no event, no crash)', () => {
const next = applyTurnResult(
ideoBase(),
mkTurnResult({ ideology: { changes: [{ target: 'ghost', to: 'fascism' }] } }),
);
expect(next.nations.uk.ideology).toBe('monarchy'); // untouched
expect(next.nations.de.ideology).toBeUndefined();
expect(next.events).toHaveLength(0);
});
it('an ambiguous target (two nations share the name) is dropped without throwing', () => {
const a: Nation = { ...mkNation('a', NS(50, 50, 50, 50), ['g1']), name: 'Same' };
const b: Nation = { ...mkNation('b', NS(50, 50, 50, 50), ['d1']), name: 'Same' };
const state = mkTurnState({ a, b }, { g1: RS(50, 50, 50, 50), d1: RS(50, 50, 50, 50) });
const next = applyTurnResult(
state,
mkTurnResult({ ideology: { changes: [{ target: 'Same', to: 'fascism' }] } }),
);
// 'Same' matches two nations -> ambiguous -> dropped; neither ideology is set.
expect(next.nations.a.ideology).toBeUndefined();
expect(next.nations.b.ideology).toBeUndefined();
expect(next.events).toHaveLength(0);
});
});
describe('buildUserPrompt — ideology', () => {
it('stamps a nation\'s ideology in the prompt nations when it has one', () => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']), ideology: 'fascism' };
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
state: { nations: Record<string, { ideology?: string }> };
};
expect(parsed.state.nations.uk.ideology).toBe('fascism');
});
it('omits a nation\'s ideology from the prompt when it has none', () => {
const uk: Nation = { ...mkNation('uk', NS(50, 50, 50, 50), ['g1']) };
const state = mkTurnState({ uk }, { g1: RS(50, 50, 50, 50) });
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
state: { nations: Record<string, { ideology?: string }> };
};
expect(parsed.state.nations.uk.ideology).toBeUndefined();
expect(JSON.stringify(parsed.state.nations.uk)).not.toContain('ideology');
});
});
});
+181 -1580
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -190,6 +190,10 @@ 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.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// 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
+4
View File
@@ -59,6 +59,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// The RegionMap built from the fixture: the authoritative region key → owner source.
+33
View File
@@ -4,7 +4,9 @@ import {
CONQUEST_MILITARY_MARGIN,
deriveNationResources,
deriveNationStats,
IDEOLOGY_STABILITY_EFFECT,
migrateRegionStats,
nationForPrompt,
seedRegionStats,
weightedMedian,
} from '../shared/protocol';
@@ -46,6 +48,10 @@ function mkState(
trades: [],
embargoes: [],
industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
@@ -337,3 +343,30 @@ describe('canConquer', () => {
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
});
});
// --- IDEOLOGY_STABILITY_EFFECT (v0.39, #62) ----------------------------------
describe('IDEOLOGY_STABILITY_EFFECT (v0.39.0 #62)', () => {
it('a regime shift is a deterministic −5 stability dip', () => {
expect(IDEOLOGY_STABILITY_EFFECT).toBe(-5);
});
});
// --- nationForPrompt — ideology (v0.39, #62) ---------------------------------
describe('nationForPrompt — ideology (v0.39.0 #62)', () => {
it('stamps the ideology when a nation has one', () => {
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
n.ideology = 'fascism';
expect(nationForPrompt(n).ideology).toBe('fascism');
// and it survives JSON serialisation (the user prompt path)
expect(JSON.stringify(nationForPrompt(n))).toContain('"ideology":"fascism"');
});
it('omits the ideology from the JSON when a nation has none (undefined key dropped)', () => {
const n = nation('uk', 'UK', S(50, 50, 50, 50), ['k1']);
expect(n.ideology).toBeUndefined();
// JSON.stringify drops undefined-valued keys → no "ideology" in the prompt
expect(JSON.stringify(nationForPrompt(n))).not.toContain('ideology');
});
});
+4
View File
@@ -118,6 +118,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
+74 -8
View File
@@ -4,6 +4,7 @@
import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RelationStatus, RoundItem } from '../shared/types';
import { ABILITIES, type AbilityField } from './abilities';
export const ADVISOR_MARKER = '<<ADVISOR>>';
@@ -19,6 +20,9 @@ export interface StubPayload {
// `leader` (v0.38.0, #61) is the per-nation named leader stamped by the
// engine; the stub ignores it — it never changes a leader (see
// deterministicTurn).
// `ideology` (v0.39.0, #62) is the per-nation form of government stamped
// by the engine; the stub ignores it — it never changes an ideology (see
// deterministicTurn).
nations: Record<
string,
{
@@ -26,6 +30,7 @@ export interface StubPayload {
territory?: string[];
nuclear?: boolean;
leader?: { name: string };
ideology?: string;
}
>;
};
@@ -95,6 +100,21 @@ 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 }>;
/**
* (v0.44.0, #66) The currently contested regions (war frontlines,
* `{ regionKey, parties }`), emitted by the engine's user prompt. The stub
* ignores this field — it never opens or resolves a contest (see
* deterministicTurn), so the contested path is covered by unit tests only.
*/
contested?: Array<{ regionKey: string; parties: string[] }>;
}
function truncate(s: string, n: number): string {
@@ -160,6 +180,31 @@ function truncate(s: string, n: number): string {
* strike (it always returns `nuclear: { arm: [], strikes: [] }`) — the
* nuclear / WMD path is covered by unit tests only, keeping the e2e
* assertions untouched.
*
* (v0.38.0, #61) The stub likewise NEVER changes a leader (it always returns
* `leaders: { changes: [] }`) — the leader path is covered by unit tests
* only, keeping the e2e assertions untouched.
*
* (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.
*
* (v0.44.0, #66) The stub likewise NEVER opens or resolves a contested region
* (a war frontline) — it always returns `contested: { open: [], resolve: [] }`
* — the contested path is covered by unit tests only, keeping the e2e
* assertions untouched.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -217,7 +262,18 @@ export function deterministicTurn(p: StubPayload): TurnResult {
},
);
return {
// Core (non-ability) turn fields, in their historical position order.
const pre: Pick<
TurnResult,
| 'date'
| 'events'
| 'stateChanges'
| 'regionChanges'
| 'relations'
| 'territoryChanges'
| 'unowned'
| 'nationOps'
> = {
date: addDays(state?.date ?? '1939-09-01', 2),
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
@@ -226,15 +282,25 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment)
takeovers: [], // the stub never performs a takeover (see doc comment)
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
};
// The 12 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 → disasters → migration → sea → contested), which must stay
// byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();
// The single field positioned after the abilities (markers last, as before).
const post: Pick<TurnResult, 'markers'> = {
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
};
return { ...pre, ...abilities, ...post } as TurnResult;
}
const CANNED_ADVISOR: string[] = [
+380
View File
@@ -510,6 +510,44 @@ export const LEADER_STABILITY_EFFECT: Record<LeaderCause, number> = {
appointed: 0,
};
// --- Ideology / form of government (v0.39, #62) ------------------------------
//
// Each nation may carry a free-form `ideology` label — its form of
// government ("democracy", "fascism", "military junta", …). The LLM changes it
// each turn via `TurnResult.ideology`: a change names a target nation (id or
// name) and the new government type. The engine sets the nation's `ideology`
// and applies a DETERMINISTIC stability dip (see `IDEOLOGY_STABILITY_EFFECT`
// below) — a regime shift always unsettles a nation — no hidden randomness.
// It is a third, independent axis next to `leader` (who is in charge) and the
// v0.32.0 takeover (which renames the nation): the id, name, territory,
// relations, and the leader all stay valid, only the form of government and
// stability move.
/** A single ideology change: the target nation and its new form of government. */
export interface IdeologyChange {
/** The target nation, by id or name (resolved by the shared resolver). */
target: string;
/** The new form of government (a free-form label, e.g. "democracy"). */
to: string;
}
/** The ideology-ops block of a `TurnResult`. Required; default `{ changes: [] }`. */
export interface IdeologyOps {
changes: IdeologyChange[];
}
/**
* The deterministic stability effect of an ideology change (v0.39, #62). A
* regime shift (a coup, a revolution, a reform, a constitutional change)
* unsettles the nation: a small −5 dip applied to the target's `stability`
* (clamped 0–100), mirrored to its `regionStats`, and its derived stats
* re-derived afterwards. Applied the same way a leader-cause effect is —
* re-setting the SAME ideology still dips (the shift just happened, even if
* the label does not change) but is idempotent for the `ideology` field
* itself.
*/
export const IDEOLOGY_STABILITY_EFFECT: number = -5;
// --- Nuclear / WMD effect math (v0.37, #60) ----------------------------------
//
// A nuclear strike is a DETERMINISTIC, devastating stat collapse — there is no
@@ -560,6 +598,238 @@ 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[];
}
/**
* (v0.44.0, #66) A single `open` op: make a region **contested** — a war
* frontline that is in **no** nation's `territory` and not in `unowned` (a
* third region state, besides "owned by one nation" and "no man's land").
* `regionKey` is the region key (or a resolvable region name); `parties` are
* two or more nation refs (ids or names), resolved via the shared
* `resolveNationRef` — the stored `ContestedRegion.parties` are the resolved
* nation ids.
*/
export interface ContestOp {
/** The region key to make contested (the model names it by key or name). */
regionKey: string;
/**
* The nations contesting the region — two or more refs (ids or names). At
* least two distinct, resolvable nations are required, and the region's
* sole owner (if any) must be at war with one of them (or, for a
* no-man's-land region, two of the parties must be at war with each other).
*/
parties: string[];
}
/**
* The contested-region block of a `TurnResult`. Required; default
* `{ open: [], resolve: [] }`. `open` makes a region a contested frontline
* (it leaves its owner's `territory` and `unowned` — sole ownership is
* preserved); `resolve` ends a contest, releasing the region to no man's
* land — unless a concurrent winning claim by one of the parties re-owns it
* (re-initialising its `regionStats`).
*/
export interface ContestedOps {
/** Regions to make contested this turn. */
open: ContestOp[];
/** Region keys to resolve (release to no man's land) this turn. */
resolve: string[];
}
/**
* A contested region — a region key that is a war frontline: in **no**
* nation's `territory` and not in `unowned`, contested by `parties` (resolved
* nation ids, two or more). Stored in `GameState.contested` (unique by
* `regionKey`).
*/
export interface ContestedRegion {
regionKey: string;
parties: string[];
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
@@ -694,6 +964,94 @@ export interface TurnResult {
* no leader replacement). Required; default `{ changes: [] }`.
*/
leaders: LeaderOps;
/**
* (v0.39, #62) Ideology (form of government) changes this turn. Each
* change names a target nation (id or name) and its new form of
* government — a free-form label such as "democracy", "fascism", or
* "military junta". Applied directly by `applyTurnResult` (like leaders /
* hybrid / economy): the target's `ideology` is set to the new label, a
* small, deterministic stability dip (`IDEOLOGY_STABILITY_EFFECT`, −5) is
* applied (clamped, mirrored to the nation's regions, re-derived), and a
* `political` narration event is appended. Re-setting the same label is
* idempotent for the field (still narrated, still dips). Unknown /
* ambiguous targets and empty labels are dropped, never thrown; the
* change never moves territory (sole ownership is untouched). Distinct
* from a takeover (which renames the nation) and from a leader change
* (who is in charge). At most 1–2 per turn, each narrated in `events`.
* 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.44.0, #66) Contested regions / frontlines: `open` makes a region a
* contested war frontline (it leaves its owner's `territory` and `unowned`
* — a third region state in no territory and not no man's land — and is
* contested by two or more nations at war); `resolve` ends a contest,
* releasing the region to no man's land unless a concurrent winning claim
* by one of the parties re-owns it. A region with a sole owner not at war
* with any party, or two or more owners, cannot be contested. Sole
* ownership is never broken; a still-contested key is not a valid normal
* claim target. At most 1–2 per turn, each narrated in `events`. Required;
* default `{ open: [], resolve: [] }`.
*/
contested: ContestedOps;
/**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small
@@ -935,6 +1293,27 @@ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
/**
* The distinct, visible fill + stroke for a **contested** region (a war
* frontline — a key that is in no nation's `territory` and not in `unowned`).
* Reddish/orange to read as a "hot" frontline on the dark basemap — clearly
* distinct from a nation color and from the no-man's-land grey. The contested
* state is decided by the client (via `GameState.contested`), never by the
* `RegionMap` (which only knows owned vs absent).
*/
export const CONTESTED_FILL = '#f97316';
export const CONTESTED_STROKE = 'rgba(255,237,213,0.55)';
/**
* The hover-tooltip sub-line for a contested region: a short "Contested:
* A vs B" line naming the contesting parties. A missing party name (a nation
* removed by a merge/split) degrades to a dash, never crashes.
*/
export function contestedTooltipLabel(parties: string[]): string {
const names = parties.map((p) => p || '—');
return `Contested: ${names.join(' vs ')}`;
}
/** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner';
@@ -1007,6 +1386,7 @@ export function nationForPrompt(n: Nation) {
territory: n.territory,
relations: n.relations,
leader: n.leader,
ideology: n.ideology,
};
}
+4
View File
@@ -461,6 +461,10 @@ 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: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
};
return state;
}
+56 -1
View File
@@ -3,7 +3,7 @@
// (The single `import type` below erases at compile time, so no runtime
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
// doc comment.)
import type { NationOp } from './protocol';
import type { NationOp, ContestedRegion } from './protocol';
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
export interface NationStats {
@@ -88,6 +88,18 @@ export interface Nation {
* each change carries a deterministic stability effect + a narration event.
*/
leader?: { name: string };
/**
* The nation's current form of government (v0.39, #62) — a free-form label
* such as `"democracy"`, `"fascism"` or `"military junta"`. Optional — a
* legacy game (or a nation the LLM has never given an ideology to) has none,
* which is the "no recorded government" baseline. The LLM changes it via
* `TurnResult.ideology` (coups / revolutions / reforms / constitutional
* changes, see `IdeologyChange` in `protocol.ts`); each change carries a
* deterministic stability dip + a political narration event. Distinct from
* the v0.32.0 takeover (which renames the nation) and from `leader` (who is
* in charge): only the form of government and stability move.
*/
ideology?: string;
}
/** Categories a narrative event can take. */
@@ -237,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
@@ -431,6 +460,32 @@ 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[];
/**
* (v0.44.0, #66) Contested regions / frontlines (see {@link ContestedRegion}):
* one entry per region key that is a war frontline — a key in **no** nation's
* `territory` and not in `unowned`, contested by two or more nations at war.
* A region becomes contested when a turn's `contested.open` succeeds (it
* leaves its owner's `territory` and `unowned` — sole ownership is preserved);
* it is resolved (released to no man's land, or re-claimed by a party) by a
* later `contested.resolve`. At most ONE entry per region key (unique by
* `regionKey`). Always `[]` on a fresh game; rows saved before v0.44 load
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
*/
contested: ContestedRegion[];
}
/**
+1
View File
@@ -834,6 +834,7 @@ function GameScreen({
unowned={game.unowned ?? []}
markers={game.markers ?? []}
regionStats={game.regionStats ?? {}}
contested={game.contested ?? []}
onFocusNation={focusNation}
/>
+72 -18
View File
@@ -9,11 +9,15 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl);
import {
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
mapFeatureOwner,
MARKER_TYPE_INFO,
nationColor,
regionFillStyle,
unownedTooltipLabel,
type ContestedRegion,
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
@@ -146,6 +150,12 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
*
* (v0.44.0, #66) A **contested** region (a war frontline — `contestedSet`, from
* `GameState.contested`) takes precedence over both unowned looks: it is in no
* nation's territory and not no man's land, so it renders the distinct
* reddish/orange `CONTESTED_FILL` / `CONTESTED_STROKE`. The state is decided by
* the client, never by the RegionMap (which has no entry for a contested key).
*/
function applyRegions(
geo: FeatureCollection,
@@ -153,6 +163,7 @@ function applyRegions(
playerNationId: string,
idKey: Record<string, string>,
unownedSet: ReadonlySet<string>,
contestedSet: ReadonlySet<string>,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
@@ -165,23 +176,36 @@ function applyRegions(
// key the RegionMap by it — never the sub-region id or the repacked id.
const key = idKey[subId] ?? '';
f.properties.regionKey = key;
const r = key ? regions[key] : undefined;
if (r) {
const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
// Ownerless: visible grey only for keys released mid-game (no man's
// land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
// (v0.44.0, #66) A contested frontline takes precedence over every other
// state (owned, unowned, no man's land): the key is in no nation's
// territory and not no man's land, so it renders the distinct contested
// fill/stroke (decided by the client from `GameState.contested`, never by
// the RegionMap, which has no entry for it).
if (key !== '' && contestedSet.has(key)) {
f.properties.fill = CONTESTED_FILL;
f.properties.stroke = CONTESTED_STROKE;
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
} else {
const r = key ? regions[key] : undefined;
if (r) {
const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId;
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
// Ownerless: visible grey only for keys released mid-game (no man's
// land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = '';
f.properties.ownerId = '';
f.properties.isPlayer = 0;
}
}
}
return out;
@@ -412,8 +436,14 @@ function hoverHtml(
noMansLand = false,
regionKey?: string,
regionStats?: Record<string, RegionStats>,
contestedParties?: string[],
): string {
if (!region || !nation) {
// (v0.44.0, #66) A contested frontline (a region in no nation's territory
// and not no man's land) names its contesting parties in the sub-line.
if (contestedParties && contestedParties.length > 0) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Contested')}</div><div class="hm-popup-sub">${contestedTooltipLabel(contestedParties)}</div></div>`;
}
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
}
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
@@ -479,6 +509,15 @@ interface Props {
* stats.
*/
regionStats?: Record<string, RegionStats>;
/**
* (v0.44.0, #66) The game's contested regions / war frontlines
* (`GameState.contested` — a region key + its contesting party ids). Such a
* region is in no nation's territory and not no man's land (a third region
* state): it renders the distinct reddish/orange `CONTESTED_FILL` and its
* hover tooltip names the parties. The map legend is unaffected (a
* contested key is absent from the RegionMap, so it adds no entry).
*/
contested?: ContestedRegion[];
}
/**
@@ -536,6 +575,7 @@ export default function WorldMap({
onFocusNation,
markers,
regionStats,
contested,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -553,6 +593,10 @@ export default function WorldMap({
const markersRef = useRef<MapMarker[]>(markers ?? []);
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
// (v0.44.0, #66) latest contested regions, region key -> contesting party
// NAMES (the stored parties are resolved nation ids, resolved here so the
// hover tooltip can show "Contested: A vs B").
const contestedRef = useRef<Map<string, string[]>>(new Map());
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
@@ -561,6 +605,11 @@ export default function WorldMap({
onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? [];
regionStatsRef.current = regionStats ?? {};
{
const m = new Map<string, string[]>();
for (const c of contested ?? []) m.set(c.regionKey, c.parties.map((id) => nations[id]?.name ?? id));
contestedRef.current = m;
}
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -721,9 +770,12 @@ export default function WorldMap({
const pKey = f.properties?.regionKey;
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
// (v0.44.0, #66) a contested frontline (in no territory, not no man's
// land) names its contesting parties in the tooltip sub-line.
const contestedParties = !region && keyStr !== undefined ? contestedRef.current.get(keyStr) : undefined;
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current, contestedParties))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
@@ -799,7 +851,7 @@ export default function WorldMap({
const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current, new Set(contestedRef.current.keys())));
};
// Stamp the current markers onto the marker source (v0.27, #44).
@@ -977,7 +1029,7 @@ export default function WorldMap({
if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet, new Set(contestedRef.current.keys())));
}
if (wasPrimed && prev && idKey) {
@@ -989,7 +1041,9 @@ export default function WorldMap({
// up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]);
}
}, [regions, playerNationId, unowned]);
// (v0.44.0, #66) re-paint when the contested set changes too — a contested
// key is absent from the RegionMap, so the diff alone wouldn't repaint it.
}, [regions, playerNationId, unowned, contested]);
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
// changes (a turn placed/removed markers) or the nations record changes