Compare commits

...
Author SHA1 Message Date
kamilkrzywda 283f9e0306 feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
CI / build + unit + e2e (pull_request) Successful in 2m20s
#80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides).
#81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn).
Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing.
2026-10-17 16:54:28 +00:00
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
31 changed files with 3187 additions and 80 deletions
+47 -1
View File
@@ -104,7 +104,7 @@ history/
│ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear / │ │ ├── <name>.ts # one module per ability: wars / takeovers / leaders / nuclear /
│ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) / │ │ │ # ideology / industry / economy / hybrid / disasters (v0.41.0 #63) /
│ │ │ # migration (v0.42.0 #64) │ │ │ # migration (v0.42.0 #64)
│ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 10-ability contract) │ │ ├── *.test.ts # per-ability unit tests (registry.test.ts pins the 12-ability contract)
│ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user) │ │ └── goldens/ # pinned prompt goldens (system-no-tool / system-tool / user)
│ └── *.test.ts # unit tests (vitest) │ └── *.test.ts # unit tests (vitest)
└── src/ └── src/
@@ -583,6 +583,52 @@ history/
12 named zones**, sole-control semantics, contest-by-higher-military, and a durable 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 `seaZones` list — a cleaner, more deterministic model that the LLM can reason about without
inventing zone names each turn. 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).** - **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 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 owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
+25
View File
@@ -2,6 +2,31 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.45.0 — 2026-10-17 08:35
- **#80: Alliances / mutual-defense treaties — a `TurnResult.alliances` lever letting the LLM form (`form`) and break (`break`) durable, symmetric pacts between two distinct nations.** A `form` op creates a pact and sets the pair's relation to `ally` (unless an active war overrides — the pact still forms but `war` stays in force); a `break` op removes the pact and sets the pair back to `neutral` (again unless an active war still binds them). A pair holds at most one pact (re-forming is a no-op). Pacts live in `GameState.alliances` and the user payload **always** carries them (even when empty). It lands as the **13th ability** of the v0.40.0 modular system, in a new `server/abilities/alliances.ts` module, **registered last** in the `ABILITIES` registry (before `space`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.alliances` persists across turns and in SQLite. Alliances never move borders (sole ownership is untouched).
- **#81: Space program / satellites — a `TurnResult.space` lever letting the LLM launch (`launch`) satellites over regions it owns and deorbit (`deorbit`) active ones by id.** Each satellite is one of four types (`recon`, `comms`, `navigation`, `weather`), gets a fresh stable id (`s1`, `s2`, …), and is stamped with the turn and date. Every active satellite grants its owner +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to every region the owner holds and re-derived — a nation with several satellites stacks the bonus. Launches/deorbits never move borders (sole ownership is untouched). It lands as the **14th ability** of the v0.40.0 modular system, in a new `server/abilities/space.ts` module, **registered last** in the `ABILITIES` registry (after `alliances`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.satellites` persists across turns and in SQLite, and the user payload **always** carries the current satellite list.
- **Types (`shared/protocol.ts`).** New `Alliance` (`{ id, parties, formedTurn, formedDate }`), `AllianceFormOp`, `AllianceBreakOp`, `AllianceOps` (`{ form, break }`); `SatelliteType` (`recon` | `comms` | `navigation` | `weather`), `SATELLITE_TYPES`, `SatelliteLaunchOp`, `SatelliteDeorbitOp`, `SpaceOps` (`{ launch, deorbit }`), `Satellite` (`{ id, by, regionKey, type, launchedTurn, launchedDate }`); `TurnResult.alliances` (required, between `contested` and `space`) and `TurnResult.space` (required, between `alliances` and `markers`).
- **Types (`shared/types.ts`).** `GameState.alliances: Alliance[]` (after `contested`) and `GameState.satellites: Satellite[]` (after `alliances`) — both required, `[]` at creation, migrated on legacy rows via the generic `ABILITIES` `stateFields` loop in `db.ts` (no `db.ts` code change needed).
- **Ability modules.** `server/abilities/alliances.ts` exports `validateAlliancesOps`, `applyAlliances`, `freshAllianceId`, and the `alliances: Ability<'alliances'>` const (`stateFields: ['alliances']`, payload always emits `{ alliances }`). `server/abilities/space.ts` exports `validateSpaceOps`, `applySpaceControl`, `applySatelliteBonuses`, `SATELLITE_STABILITY_BONUS`, `SATELLITE_TYPES`, `isSatelliteType`, `freshSatelliteId`, and the `space: Ability<'space'>` const (`stateFields: ['satellites']`, payload always emits `{ satellites }`).
- **Engine (`server/engine.ts`).** `buildUserPrompt` merges `ABILITIES[12].payload()` (alliances → `alliances`) and `ABILITIES[13].payload()` (space → `satellites`) after the contested payload merge (both always emitted, like `sea`).
- **Registry & prompt.** `alliances` and `space` appended **last** to `ABILITIES` (existing order untouched — entries 13 and 14); the system prompt gains both schema lines and constraint bullets; the user prompt gains `"alliances"` and `"satellites"` keys. The two system-prompt **goldens** and `user.txt` are regenerated.
- **Tests.** New `server/abilities/alliances.test.ts` (26 tests) and `server/abilities/space.test.ts` (30 tests), mirroring `sea.test.ts` style: `default()` / `stub()` freshness, `validate` (valid/invalid/field caps/malformed top-level), `apply` (form creates pact + `ally` relation; break removes + `neutral`; idempotent re-form; break-then-re-form reuses id; war overrides neutral/ally; unresolvable / ambiguous refs dropped; never touches `territory` / `unowned`), `isMeaningful`, `payload` (always emits), `stateFields`, and `applySatelliteBonuses` (+1 stability per satellite, clamped, mirrored, re-derived, stacks). `registry.test.ts` updated for the **14-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields` now 8 entries, composes 15 / `extended[14]`).
- **Version** bumped `0.44.0 → 0.45.0`.
## 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 ## 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. - **#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). - **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).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.43.0", "version": "0.45.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+380
View File
@@ -0,0 +1,380 @@
// Unit tests for the ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) —
// the modular, DURABLE LLM-ability that lets nations form symmetric pacts
// (`alliances.form`) and break them (`alliances.break`): a pact sets the pair's
// relation to `ally` (unless an active war overrides), breaking sets it back to
// `neutral` (unless an active war still binds them), a pair holds at most one
// pact, and the pacts live in `GameState.alliances`. The layer never touches
// `territory` / `unowned`.
// 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 an alliance op, so the apply
// path is exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import {
applyAlliances,
alliances,
freshAllianceId,
validateAlliancesOps,
} from './alliances';
import type { Alliance } from '../../shared/protocol';
import type { GameState, Nation, NationStats } from '../../shared/types';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function NS(military: number, economy: number, stability: number, population: number): NationStats {
return { military, economy, stability, population };
}
function mkNation(
id: string,
name: string,
stats: NationStats,
territory: string[],
relations: Record<string, 'ally' | 'neutral' | 'rival' | 'hostile' | 'war'> = {},
): Nation {
return { id, name, type: 'major', stats, territory, relations };
}
function mkState(
nations: Record<string, Nation>,
alliances: Alliance[] = [],
): 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: [],
contested: [],
alliances,
satellites: [],
};
}
const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn });
// ---------------------------------------------------------------------------
// freshAllianceId
// ---------------------------------------------------------------------------
describe('freshAllianceId', () => {
it('returns the first free alN id', () => {
expect(freshAllianceId(new Set())).toBe('al1');
expect(freshAllianceId(new Set(['al1']))).toBe('al2');
expect(freshAllianceId(new Set(['al1', 'al2']))).toBe('al3');
});
it('skips ids already taken (a removed pact\'s id is never reused within a turn)', () => {
expect(freshAllianceId(new Set(['al1', 'al3']))).toBe('al2');
});
});
// ---------------------------------------------------------------------------
// validateAlliancesOps
// ---------------------------------------------------------------------------
describe('validateAlliancesOps', () => {
it('accepts the { form, break } object shape', () => {
expect(
validateAlliancesOps({
form: [{ a: 'a1', b: 'b2' }],
break: [{ a: 'b2', b: 'c3' }],
}),
).toEqual({
form: [{ a: 'a1', b: 'b2' }],
break: [{ a: 'b2', b: 'c3' }],
});
});
it('drops ops with empty / missing a or b, or a === b (self-pairing)', () => {
expect(validateAlliancesOps({ form: [{ a: 'a1', b: '' }], break: [] })).toEqual({
form: [],
break: [],
});
expect(validateAlliancesOps({ form: [{ a: '', b: 'b2' }], break: [] })).toEqual({
form: [],
break: [],
});
expect(validateAlliancesOps({ form: [{ a: 'a1', b: 'a1' }], break: [] })).toEqual({
form: [],
break: [],
});
});
it('drops ops with non-object entries', () => {
expect(
validateAlliancesOps({
form: [42, 'nope', null, { a: 'a1', b: 'b2' }],
break: [],
}),
).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [] });
});
it('a missing / malformed top-level value yields { form: [], break: [] }', () => {
expect(validateAlliancesOps(undefined)).toEqual({ form: [], break: [] });
expect(validateAlliancesOps(null)).toEqual({ form: [], break: [] });
expect(validateAlliancesOps('nope')).toEqual({ form: [], break: [] });
expect(validateAlliancesOps({ form: 'nope' })).toEqual({ form: [], break: [] });
expect(validateAlliancesOps({ break: 'nope' })).toEqual({ form: [], break: [] });
});
it('caps `a` and `b` at 200 chars', () => {
const longA = 'f'.repeat(500);
const longB = 'g'.repeat(500); // different string so a !== b after capping
const out = validateAlliancesOps({ form: [{ a: longA, b: longB }], break: [] });
expect(out.form).toHaveLength(1);
expect(out.form[0].a).toHaveLength(200);
expect(out.form[0].b).toHaveLength(200);
});
});
// ---------------------------------------------------------------------------
// applyAlliances
// ---------------------------------------------------------------------------
describe('applyAlliances', () => {
it('form creates a pact and sets the pair to ally', () => {
const state = mkState({
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
});
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
expect(state.alliances).toEqual([
{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' },
]);
expect(state.nations.a1.relations.b2).toBe('ally');
expect(state.nations.b2.relations.a1).toBe('ally');
});
it('resolves `a`/`b` by unique name (not just id)', () => {
const state = mkState({
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
});
applyAlliances(state, { form: [{ a: 'Alpha', b: 'Bravo' }], break: [] }, ctx(state));
expect(state.alliances).toHaveLength(1);
expect(state.alliances[0].parties).toEqual(['a1', 'b2']);
});
it('an unresolvable ref drops the op', () => {
const state = mkState({
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
});
applyAlliances(state, { form: [{ a: 'a1', b: 'ghost' }], break: [] }, ctx(state));
expect(state.alliances).toEqual([]);
expect(state.nations.a1.relations).toEqual({});
});
it('an ambiguous name (two nations share it) drops the op', () => {
const state = mkState({
a1: mkNation('a1', 'Twin', NS(60, 55, 70, 80), []),
a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), []),
});
applyAlliances(state, { form: [{ a: 'Twin', b: 'a1' }], break: [] }, ctx(state));
// 'Twin' is ambiguous → dropped
expect(state.alliances).toEqual([]);
});
it('break removes an existing pact and sets the pair to neutral', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'ally' }),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'ally' }),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
expect(state.alliances).toEqual([]);
expect(state.nations.a1.relations.b2).toBe('neutral');
expect(state.nations.b2.relations.a1).toBe('neutral');
});
it('breaking a non-existent pact is a no-op', () => {
const state = mkState({
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
});
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
expect(state.alliances).toEqual([]);
expect(state.nations.a1.relations).toEqual({});
});
it('re-forming an existing pact is idempotent (no duplicate)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
expect(state.alliances).toHaveLength(1);
expect(state.alliances[0].formedTurn).toBe(1); // unchanged
});
it('break then re-form the same pair in one turn ends with a fresh pact', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
applyAlliances(
state,
{ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] },
ctx(state),
);
expect(state.alliances).toHaveLength(1);
expect(state.alliances[0].id).toBe('al1'); // id reused (al1 was freed by break)
expect(state.alliances[0].formedTurn).toBe(1);
});
it('form when the pair is at war: the pact forms but the relation stays war', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }),
},
[],
// wire up an active war so atWar(a1, b2) is true
);
state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
expect(state.alliances).toHaveLength(1); // pact still formed
expect(state.alliances[0].parties).toEqual(['a1', 'b2']);
// the relation stays 'war' (war overrides ally)
expect(state.nations.a1.relations.b2).toBe('war');
expect(state.nations.b2.relations.a1).toBe('war');
});
it('break when the pair is at war: the pact breaks but the relation stays war', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }];
applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state));
expect(state.alliances).toEqual([]); // pact removed
// the relation stays 'war' (war overrides neutral)
expect(state.nations.a1.relations.b2).toBe('war');
expect(state.nations.b2.relations.a1).toBe('war');
});
it('pairs are order-independent: form {a: b2, b: a1} matches an existing {a1, b2} pact', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
applyAlliances(state, { form: [{ a: 'b2', b: 'a1' }], break: [] }, ctx(state));
// already allied (order-independent) → no duplicate
expect(state.alliances).toHaveLength(1);
});
it('never touches `territory` or `unowned`', () => {
const state = mkState({
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), ['a-r1']),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), ['b-r1']),
});
applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state));
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.nations.b2.territory).toEqual(['b-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// The Ability object contract
// ---------------------------------------------------------------------------
describe('alliances ability', () => {
it('owns the "alliances" field; default() is a fresh empty { form: [], break: [] }', () => {
expect(alliances.field).toBe('alliances');
expect(alliances.default()).toEqual({ form: [], break: [] });
expect(alliances.default()).not.toBe(alliances.default());
});
it('default() is not meaningful; a populated value is', () => {
expect(alliances.isMeaningful(alliances.default())).toBe(false);
expect(alliances.isMeaningful({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toBe(true);
expect(alliances.isMeaningful({ form: [], break: [{ a: 'a1', b: 'b2' }] })).toBe(true);
});
it('validate() wraps the validator into the AllianceOps shape', () => {
expect(alliances.validate({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toEqual({
form: [{ a: 'a1', b: 'b2' }],
break: [],
});
expect(alliances.validate(undefined)).toEqual({ form: [], break: [] });
});
it('the stub value equals default() (the stub never proposes an alliance op)', () => {
expect(alliances.stub()).toEqual({ form: [], break: [] });
});
it('stateFields is [alliances] (the durable GameState field)', () => {
expect(alliances.stateFields).toEqual(['alliances']);
});
it('payload() emits alliances (even when empty / undefined)', () => {
expect(alliances.payload({ state: { alliances: [] } } as never)).toEqual({ alliances: [] });
expect(alliances.payload({ state: {} as never })).toEqual({ alliances: [] });
expect(
alliances.payload({
state: { alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }] },
} as never),
).toEqual({
alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
});
});
it('apply() runs form then break (break processed first)', () => {
const state = mkState(
{
a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []),
b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []),
},
[{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }],
);
const c = ctx(state);
alliances.apply(
state,
{ form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] },
c,
);
// break first removes al1 (freeing the id), then form creates al1 again (id reused)
expect(state.alliances).toEqual([
{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' },
]);
});
});
+172
View File
@@ -0,0 +1,172 @@
// The ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) as a modular
// LLM-ability (v0.40.0, #67). A durable, symmetric pact between two distinct
// nations: the LLM proposes `form` / `break` ops each turn and the engine
// applies them directly (against the post-territory `next` state) and persists
// the active pacts in `GameState.alliances`.
//
// A pair holds at most one pact (re-forming is a no-op; breaking a non-
// existent pact is a no-op). Forming a pact sets the pair's relation to
// `ally`; breaking one sets it back to `neutral`. In both cases an ACTIVE WAR
// between the pair overrides: the pact still forms / still breaks, but the
// `war` relation stays in force — the layer never makes peace on its own.
// Alliances never move borders (sole ownership is untouched) and are
// 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.alliances` field, and the user
// payload ALWAYS carries the current pacts (even when empty) so the model
// knows who is allied with whom.
import { atWar } from './apply-helpers';
import type { Alliance, AllianceOps } from '../../shared/protocol';
import type { GameState } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability, AbilityCtx } from './types';
/**
* (v0.45.0, #80) Generate a fresh, unused alliance id: the first free of
* `al1`, `al2`, .... The caller adds each id it creates to `taken` as it goes,
* so ids stay unique within a turn and are never reused from a removed pact.
*/
export function freshAllianceId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `al${i}`;
if (!taken.has(id)) return id;
}
}
/**
* (v0.45.0, #80) The two nation ids of an alliance entry, sorted (so a pair
* can be looked up regardless of the order the LLM names them in).
*/
function sortedParties(a: string, b: string): [string, string] {
return a < b ? [a, b] : [b, a];
}
/**
* (v0.45.0, #80) Find the index of the pact (if any) covering the pair `a` /
* `b`, order-independent. Returns -1 when no such pact exists.
*/
function pactIndex(alliances: Alliance[], a: string, b: string): number {
const [x, y] = sortedParties(a, b);
return alliances.findIndex((c) => c.parties[0] === x && c.parties[1] === y);
}
/**
* (v0.45.0, #80) Validate the raw `alliances` field of a turn into a safe
* {@link AllianceOps}. Defensive like the rest of the parser — never throws.
* Accepts the object `{ form: [...], break: [...] }` (lenient: a bare object
* with `form`/`break` arrays). Each op `{a, b}` needs non-empty `a` and `b`
* (each capped at 200 chars) with `a !== b`; a malformed op is dropped.
* Resolution of `a`/`b` to concrete nation ids happens at apply time
* (`applyAlliances`), not here. A missing / malformed top-level value yields
* `{ form: [], break: [] }`.
*/
export function validateAlliancesOps(raw: unknown): AllianceOps {
const out: AllianceOps = { form: [], break: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
for (const key of ['form', 'break'] as const) {
const list = o[key];
if (!Array.isArray(list)) continue;
for (const e of list) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const a = str(p.a, 200);
const b = str(p.b, 200);
if (!a || !b || a === b) continue; // empty / missing / self-pairing: drop
out[key].push({ a, b });
}
}
return out;
}
/**
* (v0.45.0, #80) Apply the turn's alliance ops to `next`, mutating in place
* and never throwing. `break` is processed FIRST, then `form`, so a turn that
* breaks and re-forms the same pair ends with a fresh pact. An unresolvable
* ref (unknown / ambiguous nation) drops that op.
*
* (1) BREAK: for each op, if both refs resolve to distinct nations and a pact
* covers the pair, remove the pact and set a↔b to `neutral` — UNLESS the
* pair is at war (an active war overrides: the pact breaks but the
* `war` relation stays).
* (2) FORM: for each op, if both refs resolve to distinct nations and no pact
* covers the pair, create `{ id, parties: [sorted a, b], formedTurn,
* formedDate }` and set a↔b to `ally` — UNLESS the pair is at war (the
* pact still forms, but the `war` relation stays in force). Re-forming an
* existing pact is a no-op.
*
* Sole ownership is never touched; only `alliances` and the pair's mutual
* relation change.
*/
export function applyAlliances(
next: GameState,
ops: AllianceOps,
ctx: AbilityCtx,
): void {
const alliances = next.alliances ?? [];
const wars = next.wars ?? [];
// (1) BREAK first: end existing pacts.
for (const op of ops?.break ?? []) {
const a = resolveNationRef(next, op.a);
const b = resolveNationRef(next, op.b);
if (!a || !b || a === b) continue;
const i = pactIndex(alliances, a, b);
if (i === -1) continue; // no pact to break: no-op
alliances.splice(i, 1);
// Set the pair back to neutral, unless an active war still binds them.
if (!atWar(wars, a, b)) {
const na = next.nations[a];
const nb = next.nations[b];
if (na) na.relations[b] = 'neutral';
if (nb) nb.relations[a] = 'neutral';
}
}
// (2) FORM: create new pacts (idempotent — re-forming is a no-op).
const taken = new Set(alliances.map((c) => c.id));
for (const op of ops?.form ?? []) {
const a = resolveNationRef(next, op.a);
const b = resolveNationRef(next, op.b);
if (!a || !b || a === b) continue;
if (pactIndex(alliances, a, b) !== -1) continue; // already allied: no-op
const [x, y] = sortedParties(a, b);
alliances.push({
id: freshAllianceId(taken),
parties: [x, y],
formedTurn: ctx.actingTurn,
formedDate: next.date,
});
taken.add(alliances[alliances.length - 1].id);
// Set the pair to ally, unless an active war still binds them.
if (!atWar(wars, a, b)) {
const na = next.nations[a];
const nb = next.nations[b];
if (na) na.relations[b] = 'ally';
if (nb) nb.relations[a] = 'ally';
}
}
next.alliances = alliances;
}
export const alliances: Ability<'alliances'> = {
field: 'alliances',
default: () => ({ form: [], break: [] }),
validate: (raw) => validateAlliancesOps(raw),
apply: (state, data, ctx) => applyAlliances(state, data, ctx),
schemaLine: () =>
' "alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},',
constraint: () =>
'- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair\'s relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message\'s "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).',
isMeaningful: (data) => data.form.length > 0 || data.break.length > 0,
stateFields: ['alliances'],
stub: () => ({ form: [], break: [] }),
// Alliances are DURABLE (like sea / wars / industry): the user payload
// ALWAYS carries the current pacts (even when empty) so the model knows who
// is allied with whom and can `break` a pact or avoid re-forming one.
payload: (ctx) => ({ alliances: ctx.state.alliances ?? [] }),
};
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),
})),
};
},
};
+6
View File
@@ -72,6 +72,12 @@ function mkState(
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
@@ -42,6 +42,9 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen
"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>"}]}, "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>"}]}, "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>"}]}, "sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},
"space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]} "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
} }
@@ -70,6 +73,9 @@ HARD CONSTRAINTS
- 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). - 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). - 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). - 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).
- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).
- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).
- Prefer 1-4 events per turn; keep each text 1-3 sentences. - 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 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. - Keep every stat value between 0 and 100.
+6
View File
@@ -42,6 +42,9 @@ You have a getNeighbours tool: given region keys it returns the region keys that
"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>"}]}, "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>"}]}, "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>"}]}, "sea": {"control":[{"zone":"<seaZoneId>","by":"<nation id or name>"}]},
"contested": {"open":[{"regionKey":"<region key>","parties":["<nation 1>","<nation 2>"]}],"resolve":["<region key>"]},
"alliances": {"form":[{"a":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},
"space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},
"markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]} "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}
} }
@@ -70,6 +73,9 @@ HARD CONSTRAINTS
- 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). - 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). - 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). - 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).
- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).
- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).
- Prefer 1-4 events per turn; keep each text 1-3 sentences. - 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 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. - Keep every stat value between 0 and 100.
+12 -1
View File
@@ -206,5 +206,16 @@
"type": "industry" "type": "industry"
} }
], ],
"seaZones": [] "seaZones": [],
"contested": [
{
"regionKey": "b-r1",
"parties": [
"Alpha",
"Bravo"
]
}
],
"alliances": [],
"satellites": []
} }
+39 -3
View File
@@ -1,8 +1,8 @@
// The ordered LLM-ability registry. // The ordered LLM-ability registry.
// //
// `ABILITIES` is the single source of truth for the 11 per-feature LLM abilities. // `ABILITIES` is the single source of truth for the 12 per-feature LLM abilities.
// The engine iterates it in this order: // The engine iterates it in this order:
// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology → disasters → migration → sea // 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 // for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability
// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt, // validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt,
// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check, // (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check,
@@ -25,10 +25,13 @@ import { ideology } from './ideology';
import { disasters } from './disasters'; import { disasters } from './disasters';
import { migration } from './migration'; import { migration } from './migration';
import { sea } from './sea'; import { sea } from './sea';
import { contested } from './contested';
import { alliances } from './alliances';
import { space } from './space';
import type { Ability, AbilityField } from './types'; import type { Ability, AbilityField } from './types';
/** /**
* The 11 per-feature LLM abilities in their canonical apply order. * The 12 per-feature LLM abilities in their canonical apply order.
* The order matters: it determines the order of the schema lines in the * 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 * 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 * steps, and the order of the payload fields in the user prompt. New
@@ -47,6 +50,9 @@ export const ABILITIES: readonly Ability<AbilityField>[] = [
disasters, disasters,
migration, migration,
sea, sea,
contested,
alliances,
space,
]; ];
// ── type re-exports ──────────────────────────────────────────────────────────── // ── type re-exports ────────────────────────────────────────────────────────────
@@ -173,3 +179,33 @@ export {
isSeaZoneId, isSeaZoneId,
seaZoneName, seaZoneName,
} from './sea'; } from './sea';
// ── contested (frontlines) ───────────────────────────────────────────────────
export {
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from './contested';
// ── alliances (mutual-defense treaties) ──────────────────────────────────────
export {
validateAlliancesOps,
applyAlliances,
freshAllianceId,
} from './alliances';
// ── space (satellites) ───────────────────────────────────────────────────────
export {
validateSpaceOps,
applySpaceControl,
applySatelliteBonuses,
SATELLITE_STABILITY_BONUS,
SATELLITE_TYPES,
isSatelliteType,
freshSatelliteId,
} from './space';
+6
View File
@@ -84,6 +84,12 @@ function mkState(
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
+46 -11
View File
@@ -12,7 +12,7 @@
// by the disasters feature (its payload is always empty). // by the disasters feature (its payload is always empty).
// //
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered // 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
// list of the 11 abilities; each ability's default() equals the canonical // list of the 12 abilities; each ability's default() equals the canonical
// TurnResult default for its field, is a FRESH value (never a shared // TurnResult default for its field, is a FRESH value (never a shared
// reference), its default is not "meaningful" while a populated value is, // reference), its default is not "meaningful" while a populated value is,
// and the registry composes (a hypothetical extra ability can be appended // and the registry composes (a hypothetical extra ability can be appended
@@ -76,6 +76,12 @@ import {
SEA_ZONE_IDS, SEA_ZONE_IDS,
isSeaZoneId, isSeaZoneId,
seaZoneName, seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from '../engine'; } from '../engine';
import { createPresetStore } from '../presets'; import { createPresetStore } from '../presets';
import { ABILITIES } from './index'; import { ABILITIES } from './index';
@@ -92,7 +98,7 @@ import type {
SeaZoneClaim, SeaZoneClaim,
TradeAgreement, TradeAgreement,
} from '../../shared/types'; } from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
const goldensDir = path.join(here, 'goldens'); const goldensDir = path.join(here, 'goldens');
@@ -165,6 +171,12 @@ const state: GameState = {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
// --- buildUserPrompt arguments: every optional branch exercised. --- // --- buildUserPrompt arguments: every optional branch exercised. ---
@@ -202,6 +214,10 @@ const nationResources: Record<string, RegionResources> = {
const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }]; const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }];
const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false }; const nuclear: Record<string, boolean> = { a1: true, b2: false, c3: false };
const seaZones: SeaZoneClaim[] = []; 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 — * Assert the current prompt output is byte-identical to the committed golden —
@@ -241,6 +257,9 @@ function fullDefaultTurn(): TurnResult {
disasters: { events: [] }, disasters: { events: [] },
migration: { ops: [] }, migration: { ops: [] },
sea: { control: [] }, sea: { control: [] },
contested: { open: [], resolve: [] },
alliances: { form: [], break: [] },
space: { launch: [], deorbit: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
} }
@@ -270,11 +289,17 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] {
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] }; return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
case 'sea': case 'sea':
return { control: [{ zone: 'pacific', by: 'a1' }] }; return { control: [{ zone: 'pacific', by: 'a1' }] };
case 'contested':
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
case 'alliances':
return { form: [{ a: 'a1', b: 'b2' }], break: [] };
case 'space':
return { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] };
} }
} }
describe('ABILITIES registry (v0.40.0, #67)', () => { describe('ABILITIES registry (v0.40.0, #67)', () => {
it('is the canonical 11-ability order (takeovers → sea)', () => { it('is the canonical 12-ability order (takeovers → contested)', () => {
expect(ABILITIES.map((a) => a.field)).toEqual([ expect(ABILITIES.map((a) => a.field)).toEqual([
'takeovers', 'takeovers',
'wars', 'wars',
@@ -287,13 +312,16 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'disasters', 'disasters',
'migration', 'migration',
'sea', 'sea',
'contested',
'alliances',
'space',
]); ]);
}); });
it('lists 11 distinct fields, each a key of TurnResult', () => { it('lists 14 distinct fields, each a key of TurnResult', () => {
const fields = ABILITIES.map((a) => a.field); const fields = ABILITIES.map((a) => a.field);
expect(fields).toHaveLength(11); expect(fields).toHaveLength(14);
expect(new Set(fields).size).toBe(11); expect(new Set(fields).size).toBe(14);
const all = fullDefaultTurn(); const all = fullDefaultTurn();
for (const f of fields) expect(f in all).toBe(true); for (const f of fields) expect(f in all).toBe(true);
}); });
@@ -324,6 +352,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
industryProjects, industryProjects,
nuclear, nuclear,
seaZones, seaZones,
contested,
), ),
); );
}); });
@@ -353,13 +382,13 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
it('declares exactly the durable GameState fields the db migration must default', () => { it('declares exactly the durable GameState fields the db migration must default', () => {
const owned: string[] = []; const owned: string[] = [];
for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields); for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields);
expect([...owned].sort()).toEqual(['embargoes', 'industry', 'seaZones', 'trades', 'wars']); expect([...owned].sort()).toEqual(['alliances', 'contested', 'embargoes', 'industry', 'satellites', 'seaZones', 'trades', 'wars']);
}); });
it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => { 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 // The registry is a plain ordered array the engine iterates uniformly. A
// structural (field-name-agnostic) loop over the abilities — schema line, // structural (field-name-agnostic) loop over the abilities — schema line,
// constraint bullet — must keep working when an 11th ability is appended. // constraint bullet — must keep working when a 12th ability is appended.
// This documents the extension point without widening the closed // This documents the extension point without widening the closed
// AbilityField union. // AbilityField union.
interface Loopable { interface Loopable {
@@ -378,9 +407,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
'custom', 'custom',
]); ]);
// every uniform loop runs over all N abilities, the appended one included: // every uniform loop runs over all N abilities, the appended one included:
expect(extended.map((a) => a.schemaLine())).toHaveLength(12); expect(extended.map((a) => a.schemaLine())).toHaveLength(15);
expect(extended.map((a) => a.constraint())).toHaveLength(12); expect(extended.map((a) => a.constraint())).toHaveLength(15);
expect(extended[11].schemaLine()).toBe(' "custom": [],'); expect(extended[14].schemaLine()).toBe(' "custom": [],');
}); });
it('the engine still re-exports every name its callers import from it', () => { it('the engine still re-exports every name its callers import from it', () => {
@@ -439,6 +468,12 @@ describe('ABILITIES registry (v0.40.0, #67)', () => {
SEA_ZONE_IDS, SEA_ZONE_IDS,
isSeaZoneId, isSeaZoneId,
seaZoneName, seaZoneName,
// contested (v0.44.0, #66)
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
}; };
for (const [name, value] of Object.entries(reexported)) { for (const [name, value] of Object.entries(reexported)) {
expect(value, `engine should re-export ${name}`).toBeDefined(); expect(value, `engine should re-export ${name}`).toBeDefined();
+6
View File
@@ -85,6 +85,12 @@ function mkState(
embargoes: [], embargoes: [],
industry: [], industry: [],
seaZones, seaZones,
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
+413
View File
@@ -0,0 +1,413 @@
// Unit tests for the SPACE PROGRAM / satellites ability (v0.45.0, #81) — the
// modular, DURABLE LLM-ability that lets nations launch satellites
// (`space.launch`) over regions they own and deorbit them (`space.deorbit`):
// each active satellite grants its owner +1 stability per committed turn
// (`SATELLITE_STABILITY_BONUS`), mirrored to its regions and re-derived, and a
// nation with several satellites stacks the bonus. The layer never touches
// `territory` / `unowned`.
// 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 space op, so the apply path is
// exercised here, not by the e2e suite.
import { describe, expect, it } from 'vitest';
import {
applySatelliteBonuses,
applySpaceControl,
isSatelliteType,
space,
validateSpaceOps,
SATELLITE_STABILITY_BONUS,
SATELLITE_TYPES,
} from './space';
import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types';
import type { Satellite } from '../../shared/protocol';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function NS(military: number, economy: number, stability: number, population: number): NationStats {
return { military, economy, stability, population };
}
function RS(military: number, economy: number, stability: number, population: number): RegionStats {
return { military, economy, stability, population };
}
function mkNation(
id: string,
name: string,
stats: NationStats,
territory: string[],
): Nation {
return { id, name, type: 'major', stats, territory, relations: {} };
}
function mkState(
nations: Record<string, Nation>,
regionStats: Record<string, RegionStats>,
satellites: Satellite[] = [],
): 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: [],
contested: [],
alliances: [],
satellites,
};
}
const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn, validRegionKeys: new Set(['a-r1', 'b-r1', 'c-r1']) });
// ---------------------------------------------------------------------------
// SATELLITE_TYPES / isSatelliteType
// ---------------------------------------------------------------------------
describe('SATELLITE_TYPES / isSatelliteType', () => {
it('exposes exactly the 4 satellite types', () => {
expect(SATELLITE_TYPES).toEqual(['recon', 'comms', 'navigation', 'weather']);
});
it('isSatelliteType accepts each known type and rejects unknown / non-strings', () => {
for (const t of SATELLITE_TYPES) expect(isSatelliteType(t)).toBe(true);
expect(isSatelliteType('weapon')).toBe(false);
expect(isSatelliteType('')).toBe(false);
expect(isSatelliteType(42 as unknown as string)).toBe(false);
});
it('SATELLITE_STABILITY_BONUS is +1', () => {
expect(SATELLITE_STABILITY_BONUS).toBe(1);
});
});
// ---------------------------------------------------------------------------
// validateSpaceOps
// ---------------------------------------------------------------------------
describe('validateSpaceOps', () => {
it('accepts the { launch, deorbit } object shape', () => {
const op = { by: 'a1', regionKey: 'a-r1', type: 'recon' };
expect(validateSpaceOps({ launch: [op], deorbit: ['s1'] })).toEqual({
launch: [op],
deorbit: ['s1'],
});
});
it('drops a launch op with an unknown / missing type', () => {
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'weapon' }], deorbit: [] })).toEqual({
launch: [],
deorbit: [],
});
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1' }], deorbit: [] })).toEqual({
launch: [],
deorbit: [],
});
});
it('drops a launch op with an empty / missing `by` or `regionKey`', () => {
expect(validateSpaceOps({ launch: [{ by: '', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
launch: [],
deorbit: [],
});
expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: '', type: 'recon' }], deorbit: [] })).toEqual({
launch: [],
deorbit: [],
});
});
it('drops a deorbit entry that is empty / non-string', () => {
expect(validateSpaceOps({ launch: [], deorbit: ['', 42, null, 's1'] as unknown as string[] })).toEqual({
launch: [],
deorbit: ['s1'],
});
});
it('dedupes deorbit ids', () => {
expect(validateSpaceOps({ launch: [], deorbit: ['s1', 's1', 's2', 's2'] })).toEqual({
launch: [],
deorbit: ['s1', 's2'],
});
});
it('caps `by` at 200 and `regionKey` at 120 chars', () => {
const longBy = 'f'.repeat(500);
const longKey = 'g'.repeat(500);
const out = validateSpaceOps({ launch: [{ by: longBy, regionKey: longKey, type: 'recon' }], deorbit: [] });
expect(out.launch).toHaveLength(1);
expect(out.launch[0].by).toHaveLength(200);
expect(out.launch[0].regionKey).toHaveLength(120);
});
it('a missing / malformed top-level value yields { launch: [], deorbit: [] }', () => {
expect(validateSpaceOps(undefined)).toEqual({ launch: [], deorbit: [] });
expect(validateSpaceOps(null)).toEqual({ launch: [], deorbit: [] });
expect(validateSpaceOps('nope')).toEqual({ launch: [], deorbit: [] });
expect(validateSpaceOps({ launch: 'nope' })).toEqual({ launch: [], deorbit: [] });
expect(validateSpaceOps({ deorbit: 'nope' })).toEqual({ launch: [], deorbit: [] });
});
});
// ---------------------------------------------------------------------------
// applySpaceControl (deorbit first, then launch)
// ---------------------------------------------------------------------------
describe('applySpaceControl', () => {
it('a launch adds a fresh satellite stamped with the turn and date', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
);
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
expect(state.satellites).toEqual([
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
]);
});
it('resolves `by` by unique name (not just id)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
);
applySpaceControl(state, { launch: [{ by: 'Alpha', regionKey: 'a-r1', type: 'comms' }], deorbit: [] }, ctx(state));
expect(state.satellites).toHaveLength(1);
expect(state.satellites[0].by).toBe('a1');
});
it('an unresolvable `by` drops the launch', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
);
applySpaceControl(state, { launch: [{ by: 'ghost', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
expect(state.satellites).toEqual([]);
});
it('an unowned or invalid regionKey drops the launch', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
);
// a-r2 is not owned by a1
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r2', type: 'recon' }], deorbit: [] }, ctx(state));
expect(state.satellites).toEqual([]);
// not-a-key is not a valid key
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'not-a-key', type: 'recon' }], deorbit: [] }, ctx(state));
expect(state.satellites).toEqual([]);
});
it('deorbit removes satellites by id; an absent id 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) },
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySpaceControl(state, { launch: [], deorbit: ['s1', 's999'] }, ctx(state));
expect(state.satellites).toEqual([]);
});
it('deorbit is processed before launch in the same turn', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySpaceControl(
state,
{ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'comms' }], deorbit: ['s1'] },
ctx(state),
);
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1 — id reused
expect(state.satellites).toEqual([
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
]);
});
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) },
);
applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state));
expect(state.nations.a1.territory).toEqual(['a-r1']);
expect(state.unowned).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// applySatelliteBonuses
// ---------------------------------------------------------------------------
describe('applySatelliteBonuses', () => {
it('each active satellite grants its owner +1 stability (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) },
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySatelliteBonuses(state);
// +1 stability, mirrored to the region, re-derived (single region → back to region's value)
expect(state.nations.a1.stats).toEqual(NS(50, 50, 51, 50));
expect(state.regionStats['a-r1']).toEqual(RS(50, 50, 51, 50));
});
it('clamps to 0 and 100', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 99, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 99, 50) },
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySatelliteBonuses(state);
expect(state.nations.a1.stats.stability).toBe(100);
expect(state.regionStats['a-r1'].stability).toBe(100);
});
it('a nation with several satellites stacks the bonus (+1 per satellite)', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' },
{ id: 's2', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
{ id: 's3', by: 'a1', regionKey: 'a-r1', type: 'weather', launchedTurn: 1, launchedDate: '1939-09-01' },
],
);
applySatelliteBonuses(state);
expect(state.nations.a1.stats.stability).toBe(53);
expect(state.regionStats['a-r1'].stability).toBe(53);
});
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), []) },
{},
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySatelliteBonuses(state);
// direct mutation, no re-derive (no regions)
expect(state.nations.a1.stats.stability).toBe(51);
});
it('a stale satellite (nation no longer in `nations`) is skipped; the satellite is kept', () => {
const state = mkState(
{ a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) },
{ 'a-r1': RS(50, 50, 50, 50) },
[{ id: 's1', by: 'ghost', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
applySatelliteBonuses(state);
// ghost is not in nations → skipped; a1 is unaffected
expect(state.nations.a1.stats.stability).toBe(50);
// the satellite is kept (not removed)
expect(state.satellites).toHaveLength(1);
});
it('an empty satellites 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) },
[],
);
applySatelliteBonuses(state);
expect(state.nations.a1.stats.stability).toBe(50);
});
});
// ---------------------------------------------------------------------------
// The Ability object contract
// ---------------------------------------------------------------------------
describe('space ability', () => {
it('owns the "space" field; default() is a fresh empty { launch: [], deorbit: [] }', () => {
expect(space.field).toBe('space');
expect(space.default()).toEqual({ launch: [], deorbit: [] });
expect(space.default()).not.toBe(space.default());
});
it('default() is not meaningful; a populated value is', () => {
expect(space.isMeaningful(space.default())).toBe(false);
expect(space.isMeaningful({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toBe(true);
expect(space.isMeaningful({ launch: [], deorbit: ['s1'] })).toBe(true);
});
it('validate() wraps the validator into the SpaceOps shape', () => {
expect(space.validate({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({
launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }],
deorbit: [],
});
expect(space.validate(undefined)).toEqual({ launch: [], deorbit: [] });
});
it('the stub value equals default() (the stub never proposes a space op)', () => {
expect(space.stub()).toEqual({ launch: [], deorbit: [] });
});
it('stateFields is [satellites] (the durable GameState field)', () => {
expect(space.stateFields).toEqual(['satellites']);
});
it('payload() emits satellites (even when empty / undefined)', () => {
expect(space.payload({ state: { satellites: [] } } as never)).toEqual({ satellites: [] });
expect(space.payload({ state: {} as never })).toEqual({ satellites: [] });
expect(
space.payload({
state: { satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }] },
} as never),
).toEqual({
satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
});
});
it('apply() runs deorbit, then launch, 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(50, 50, 50, 50), ['b-r1']),
},
{ 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) },
[{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }],
);
const c = ctx(state);
space.apply(
state,
{
launch: [
{ by: 'a1', regionKey: 'a-r1', type: 'comms' },
// an unresolvable ref — must be dropped, not throw
{ by: 'ghost', regionKey: 'c-r1', type: 'weather' },
],
deorbit: ['s1'],
},
c,
);
// s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1
expect(state.satellites).toEqual([
{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' },
]);
// a1 gets +1 stability (one satellite)
expect(state.nations.a1.stats.stability).toBe(51);
// b2 is unaffected
expect(state.nations.b2.stats.stability).toBe(50);
});
});
+215
View File
@@ -0,0 +1,215 @@
// The SPACE PROGRAM / satellites ability (v0.45.0, #81) as a modular
// LLM-ability (v0.40.0, #67). Durable per-turn space ops: the LLM launches
// satellites (one of four `SatelliteType`s) over regions it owns and deorbits
// active ones by id. Every active satellite grants its owner a deterministic
// +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to
// every region the owner holds (`regionStats`), then the owner's derived
// `stats` are re-derived — so a nation with several satellites stacks the
// bonus. Launches/deorbits never move borders (sole ownership is untouched).
//
// Each turn the LLM may `launch` new satellites (each op names `by`, a valid
// region key owned by `by`, and a type) and/or `deorbit` active satellites
// (by their stable id). A launch is dropped when `by` does not resolve, when
// `regionKey` is not a valid key owned by `by`, or when `type` is not a known
// `SatelliteType`; a deorbit of an unknown id is a no-op. New satellites get
// a fresh id (`s1`, `s2`, …) and are stamped with `launchedTurn` /
// `launchedDate`.
//
// This is a DURABLE ability (unlike the turn-transient hybrid / disasters /
// migration layers): it owns the `GameState.satellites` field, and the user
// payload ALWAYS carries the current satellite list (even when empty) so the
// model knows what is in orbit and can deorbit it.
import { clampStat, deriveNationStats, isSatelliteType } from '../../shared/protocol';
import type { SpaceOps } from '../../shared/protocol';
export { SATELLITE_TYPES, isSatelliteType } from '../../shared/protocol';
import type { GameState, Nation } from '../../shared/types';
import { resolveNationRef, str } from './shared';
import type { Ability, AbilityCtx } from './types';
/**
* (v0.45.0, #81) The deterministic per-turn stability bonus each active
* satellite grants its owner (clamped to 0–100, mirrored to every region the
* owner holds, then the owner's derived `stats` re-derived). A nation with
* several satellites stacks the bonus (one `+1` per satellite).
*/
export const SATELLITE_STABILITY_BONUS = 1;
/**
* (v0.45.0, #81) Generate a fresh, unused satellite id: the first free of
* `s1`, `s2`, .... The caller adds each id it creates to `taken` as it goes.
*/
export function freshSatelliteId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `s${i}`;
if (!taken.has(id)) return id;
}
}
/**
* (v0.45.0, #81) Validate the raw `space` field of a turn into a safe
* {@link SpaceOps}. Defensive like the rest of the parser — never throws.
* Accepts the object `{ launch: [...], deorbit: [...] }`. Each launch op
* needs a non-empty `by` (capped at 200 chars), a non-empty `regionKey`
* (capped at 120 chars), and a `type` that is one of {@link SATELLITE_TYPES};
* an invalid op is dropped. Each deorbit entry needs a non-empty id (capped
* at 120 chars); deorbit ids are deduped. Resolution of `by` to a concrete
* nation and the ownership check on `regionKey` happen at apply time
* (`applySpaceControl`), not here. A missing / malformed top-level value
* yields `{ launch: [], deorbit: [] }`.
*/
export function validateSpaceOps(raw: unknown): SpaceOps {
const out: SpaceOps = { launch: [], deorbit: [] };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
const o = raw as Record<string, unknown>;
if (Array.isArray(o.launch)) {
const seenLaunch = new Set<string>();
for (const e of o.launch) {
if (!e || typeof e !== 'object') continue;
const p = e as Record<string, unknown>;
const by = str(p.by, 200);
const regionKey = str(p.regionKey, 120);
if (!by || !regionKey) continue;
if (!isSatelliteType(p.type)) continue;
const id = `${by}\u0000${regionKey}\u0000${p.type}`;
if (seenLaunch.has(id)) continue; // dedupe identical launch ops
seenLaunch.add(id);
out.launch.push({ by, regionKey, type: p.type });
}
}
if (Array.isArray(o.deorbit)) {
const seenDeorbit = new Set<string>();
for (const e of o.deorbit) {
const id = str(e, 120);
if (!id) continue; // empty / missing / non-string: drop
if (seenDeorbit.has(id)) continue; // dedupe
seenDeorbit.add(id);
out.deorbit.push(id);
}
}
return out;
}
/**
* (v0.45.0, #81) Remove the satellites whose ids are in `deorbit` from
* `next.satellites`. An absent id is a no-op.
*/
function deorbitSatellites(next: GameState, deorbit: string[]): void {
if (deorbit.length === 0) return;
const ids = new Set(deorbit);
next.satellites = (next.satellites ?? []).filter((s) => !ids.has(s.id));
}
/**
* (v0.45.0, #81) Launch this turn's satellites: resolve `by` (id first, then
* a case-insensitive unique name), require `regionKey` to be a valid key
* owned by `by`, and append a fresh `{ id, by, regionKey, type, launchedTurn,
* launchedDate }` per op. Unknown / ambiguous `by`, an unowned or invalid
* `regionKey`, or an unknown `type` (already filtered at validate time) drops
* the op. Never throws.
*/
function launchSatellites(next: GameState, ops: SpaceOps['launch'], ctx: AbilityCtx): void {
const validKeys = ctx.validRegionKeys;
const taken = new Set((next.satellites ?? []).map((s) => s.id));
for (const op of ops) {
const by = resolveNationRef(next, op.by);
if (!by) continue; // unresolvable launcher: drop
const key = op.regionKey;
if (validKeys && !validKeys.has(key)) continue; // not a valid region key: drop
const owner = next.nations[by];
if (!owner || !owner.territory.includes(key)) continue; // by does not own it: drop
next.satellites = (next.satellites ?? []).concat({
id: freshSatelliteId(taken),
by,
regionKey: key,
type: op.type,
launchedTurn: ctx.actingTurn,
launchedDate: next.date,
});
taken.add(next.satellites[next.satellites.length - 1].id);
}
}
/**
* (v0.45.0, #81) The per-turn satellite bonus, applied EVERY committed turn
* (not just to this turn's launches): each active satellite grants its owner
* +`SATELLITE_STABILITY_BONUS` stability (clamped to 0–100), mirrored to every
* region the owner holds (`regionStats`), then the owner's derived `stats` are
* re-derived (the same 0-region / no-entry guards as the economy / migration
* layers — a 0-region nation keeps its directly-mutated value). A nation with
* several satellites stacks the bonus (one `+1` per satellite). A claim
* whose nation no longer exists (removed by a merge / split this turn) is
* skipped. Never throws.
*/
export function applySatelliteBonuses(next: GameState, geoOf?: (key: string) => { area: number; population: number } | undefined): void {
const satellites = next.satellites ?? [];
if (satellites.length === 0) return;
// nation id -> number of satellites it owns (stacks the bonus).
const counts = new Map<string, number>();
for (const s of satellites) {
counts.set(s.by, (counts.get(s.by) ?? 0) + 1);
}
const applyNation = (n: Nation, count: number): void => {
n.stats.stability = clampStat(n.stats.stability + count * SATELLITE_STABILITY_BONUS);
for (const key of n.territory) {
const rs = next.regionStats[key];
if (!rs) continue;
rs.stability = clampStat(rs.stability + count * SATELLITE_STABILITY_BONUS);
}
// Re-derive so the effect persists (the main recompute ran earlier this
// turn). A nation with no regionStats entry keeps its directly-mutated value.
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
}
};
for (const [id, count] of counts) {
const n = next.nations[id];
if (!n) continue; // nation removed this turn: bonus dropped (satellite kept)
applyNation(n, count);
}
}
/**
* (v0.45.0, #81) Apply the turn's space 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
* ownership it inspects is final for this turn.
*
* (1) DEORBIT first: remove satellites by id (absent id = no-op).
* (2) LAUNCH: for each launch op, resolve `by`, require `regionKey` to be a
* valid key owned by `by`, and append a fresh satellite stamped with
* `launchedTurn` / `launchedDate`.
* (3) PER-TURN BONUS: every active satellite grants its owner +1 stability
* (clamped, mirrored to its regions, re-derived) — one per satellite.
*
* Sole ownership is never touched; only `satellites` and stability change.
*/
export function applySpaceControl(next: GameState, ops: SpaceOps, ctx: AbilityCtx): void {
deorbitSatellites(next, ops?.deorbit ?? []);
launchSatellites(next, ops?.launch ?? [], ctx);
applySatelliteBonuses(next, ctx.geoOf);
}
export const space: Ability<'space'> = {
field: 'space',
default: () => ({ launch: [], deorbit: [] }),
validate: (raw) => validateSpaceOps(raw),
apply: (state, data, ctx) => applySpaceControl(state, data, ctx),
schemaLine: () =>
' "space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},',
constraint: () =>
'- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message\'s "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).',
isMeaningful: (data) => data.launch.length > 0 || data.deorbit.length > 0,
stateFields: ['satellites'],
stub: () => ({ launch: [], deorbit: [] }),
// Space is DURABLE (like sea / wars / industry): the user payload ALWAYS
// carries the current satellites (even when empty) so the model knows what
// is in orbit and can deorbit it.
payload: (ctx) => ({ satellites: ctx.state.satellites ?? [] }),
};
+8 -4
View File
@@ -15,14 +15,14 @@ import type {
SeaZoneClaim, SeaZoneClaim,
TradeAgreement, TradeAgreement,
} from '../../shared/types'; } from '../../shared/types';
import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; import type { ContestedRegion, MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol';
/** /**
* The 11 per-feature LLM abilities a turn can exercise, by the `TurnResult` * 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` * 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 * 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 * core engine effects (events / stats / territory / no-man's-land / nation-ops
* / markers) plus, in order, these eleven abilities. * / markers) plus, in order, these twelve abilities.
*/ */
export type AbilityField = export type AbilityField =
| 'takeovers' | 'takeovers'
@@ -35,7 +35,10 @@ export type AbilityField =
| 'ideology' | 'ideology'
| 'disasters' | 'disasters'
| 'migration' | 'migration'
| 'sea'; | 'sea'
| 'contested'
| 'alliances'
| 'space';
/** /**
* Everything a single ability's `apply` may need from the surrounding turn. * Everything a single ability's `apply` may need from the surrounding turn.
@@ -92,6 +95,7 @@ export interface UserPromptCtx {
readonly nationResources?: Record<string, RegionResources>; readonly nationResources?: Record<string, RegionResources>;
readonly industryProjects?: IndustryProject[]; readonly industryProjects?: IndustryProject[];
readonly seaZones?: SeaZoneClaim[]; readonly seaZones?: SeaZoneClaim[];
readonly contested?: ContestedRegion[];
} }
/** /**
+17
View File
@@ -40,6 +40,12 @@ function makeState(id: string): GameState {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
@@ -257,6 +263,17 @@ describe('openDb', () => {
db.close(); 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', () => { it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:'); const db = openDb(':memory:');
db.save(makeState('a')); db.save(makeState('a'));
+53 -38
View File
@@ -135,6 +135,12 @@ function baseState(): GameState {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
@@ -572,7 +578,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}); });
expect(withOps).not.toBeNull(); expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -594,7 +600,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}); });
expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -615,7 +621,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, // new proposal this turn
}); });
expect(next.pendingNationOps).toEqual([ expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -634,7 +640,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}); });
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
}); });
@@ -1091,7 +1097,7 @@ describe('applyTurnResult', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
applyTurnResult(state, result); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
@@ -1108,7 +1114,7 @@ describe('applyTurnResult', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); expect(next.pending).toEqual([]);
@@ -1131,7 +1137,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE. // 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: [] }], territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); 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 // the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1157,7 +1163,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned. // released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1175,7 +1181,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [], regionChanges: [],
relations: [], relations: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1265,7 +1271,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1380,7 +1386,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect( expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } }),
).toBe(true); ).toBe(true);
}); });
@@ -1687,9 +1693,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => { describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => { it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true); 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(true); expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] } })).toBe(false); 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, 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)', () => { it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1702,7 +1708,7 @@ describe('isMeaningful', () => {
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [], unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -1723,7 +1729,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -1744,7 +1750,7 @@ describe('isMeaningful', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -1767,7 +1773,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich'); expect(next.nations.de.name).toBe('The Reich');
@@ -1800,7 +1806,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.playerNationId).toBe('uk'); // player still leads it expect(next.playerNationId).toBe('uk'); // player still leads it
@@ -1824,7 +1830,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
// no nation was renamed // no nation was renamed
@@ -1851,7 +1857,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
// neither was renamed (ambiguous) // neither was renamed (ambiguous)
@@ -1877,7 +1883,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}); });
expect(validated!.takeovers).toEqual([]); expect(validated!.takeovers).toEqual([]);
const next = applyTurnResult(state, validated!); const next = applyTurnResult(state, validated!);
@@ -1903,7 +1909,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.nations.uk.name).toBe('The Commonwealth'); expect(next.nations.uk.name).toBe('The Commonwealth');
@@ -1927,7 +1933,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.version).toBe(2); // exactly one bump for the whole turn expect(next.version).toBe(2); // exactly one bump for the whole turn
@@ -1949,7 +1955,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.nations.de.name).toBe('The Reich'); expect(next.nations.de.name).toBe('The Reich');
@@ -1983,7 +1989,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
}; };
const next = applyTurnResult(state, result, undefined, centroidOf); const next = applyTurnResult(state, result, undefined, centroidOf);
expect(next.markers).toHaveLength(1); expect(next.markers).toHaveLength(1);
@@ -2007,13 +2013,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, { const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add, remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(first.markers).toHaveLength(1); expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again // a second turn proposes the exact same marker again
const second = applyTurnResult(first, { const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add, remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(second.markers).toHaveLength(1); expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -2040,7 +2046,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: {
add: [ add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
{ type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped
@@ -2087,7 +2093,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: {
add: [ add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewA', regionKey: 'new-a' },
{ type: 'city', name: 'NewB', regionKey: 'new-b' }, { type: 'city', name: 'NewB', regionKey: 'new-b' },
@@ -2112,7 +2118,7 @@ describe('markers (point placement, v0.27 #44)', () => {
]; ];
const next = applyTurnResult(state, { const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(next.markers).toHaveLength(1); expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2128,14 +2134,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless // 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, { const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull(); expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de // 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, { const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, 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: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf); }, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de'); expect(reclaimed.markers[0].ownerNationId).toBe('de');
}); });
@@ -2205,7 +2211,7 @@ describe('markers (point placement, v0.27 #44)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
}); });
const { state } = await runTurn({ const { state } = await runTurn({
llm: fakeLlm([result]), llm: fakeLlm([result]),
@@ -2282,6 +2288,12 @@ function mkTurnState(
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
} }
@@ -2306,6 +2318,9 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
disasters: partial.disasters ?? { events: [] }, disasters: partial.disasters ?? { events: [] },
migration: partial.migration ?? { ops: [] }, migration: partial.migration ?? { ops: [] },
sea: partial.sea ?? { control: [] }, sea: partial.sea ?? { control: [] },
contested: partial.contested ?? { open: [], resolve: [] },
alliances: partial.alliances ?? { form: [], break: [] },
space: partial.space ?? { launch: [], deorbit: [] },
markers: partial.markers ?? { add: [], remove: [] }, markers: partial.markers ?? { add: [], remove: [] },
}; };
} }
@@ -3031,7 +3046,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}); });
expect(withTakeovers).not.toBeNull(); expect(withTakeovers).not.toBeNull();
// the valid one survives, the empty-name one is dropped // the valid one survives, the empty-name one is dropped
@@ -3419,7 +3434,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -3440,7 +3455,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(true); ).toBe(true);
}); });
@@ -3461,7 +3476,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
hybrid: { ops: [] }, hybrid: { ops: [] },
economy: { trade: [], embargo: [] }, industry: { projects: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] },
nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] },
ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, markers: { add: [], remove: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] },
}), }),
).toBe(false); ).toBe(false);
}); });
+50
View File
@@ -35,6 +35,7 @@ import {
markerId, markerId,
MAX_MARKERS, MAX_MARKERS,
nationForPrompt, nationForPrompt,
type ContestedRegion,
type MarkerInfo, type MarkerInfo,
type MarkerSpec, type MarkerSpec,
type NationOp, type NationOp,
@@ -113,6 +114,11 @@ export {
SEA_ZONE_IDS, SEA_ZONE_IDS,
isSeaZoneId, isSeaZoneId,
seaZoneName, seaZoneName,
validateContestedOps,
applyContested,
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
} from './abilities'; } from './abilities';
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) --- // --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
@@ -393,6 +399,14 @@ export function buildUserPrompt(
* `TurnResult.sea`. Omitted entirely when undefined. * `TurnResult.sea`. Omitted entirely when undefined.
*/ */
seaZones?: SeaZoneClaim[], seaZones?: SeaZoneClaim[],
/**
* (v0.44.0, #66) The game's current contested regions (each a `{ regionKey,
* parties }` pair — a war frontline in no nation's territory and not in
* `unowned`). Emitted verbatim (even when empty) so the model knows which
* regions are frontlines (and can `resolve` them or see their parties);
* omitted entirely when undefined.
*/
contested?: ContestedRegion[],
): string { ): string {
const player = state.nations[state.playerNationId]; const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId; const playerName = player?.name ?? state.playerNationId;
@@ -503,6 +517,7 @@ export function buildUserPrompt(
nationResources, nationResources,
industryProjects, industryProjects,
seaZones, seaZones,
contested,
}; };
Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks Object.assign(payload, ABILITIES[0].payload(upCtx)); // takeovers → takeoverRisks
if (noMansLand !== undefined) payload.unowned = noMansLand; if (noMansLand !== undefined) payload.unowned = noMansLand;
@@ -512,6 +527,9 @@ export function buildUserPrompt(
Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes Object.assign(payload, ABILITIES[3].payload(upCtx)); // economy → trades, embargoes
Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry
Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted) Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted)
Object.assign(payload, ABILITIES[11].payload(upCtx)); // contested → contested (only when non-empty)
Object.assign(payload, ABILITIES[12].payload(upCtx)); // alliances → alliances (always emitted)
Object.assign(payload, ABILITIES[13].payload(upCtx)); // space → satellites (always emitted)
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} }
@@ -653,6 +671,9 @@ export function validateTurnResult(
disasters: { events: [] }, disasters: { events: [] },
migration: { ops: [] }, migration: { ops: [] },
sea: { control: [] }, sea: { control: [] },
contested: { open: [], resolve: [] },
alliances: { form: [], break: [] },
space: { launch: [], deorbit: [] },
markers: { add: [], remove: [] }, markers: { add: [], remove: [] },
}; };
@@ -940,6 +961,9 @@ export function applyTurnResult(
// (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field // (v0.43.0, #65) sea-zone control; a legacy/corrupt clone without the field
// starts empty (the turn then upserts this turn's claims into it). // starts empty (the turn then upserts this turn's claims into it).
next.seaZones = next.seaZones ?? []; next.seaZones = next.seaZones ?? [];
// (v0.44.0, #66) contested regions (war frontlines); a legacy/corrupt clone
// without the field starts empty (the turn then opens/resolves contests).
next.contested = next.contested ?? [];
const actingTurn = state.turn; // this action resolves the current turn const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2); next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -1035,12 +1059,34 @@ export function applyTurnResult(
} }
set.add(gainer); set.add(gainer);
} }
// (v0.44.0, #66) Contested-region guard for the claims step below: a region
// that is currently a war frontline (in `state.contested`) sits in no
// nation's territory, so a normal `gain` would otherwise treat it as a free
// re-claim. A contested key may only be claimed by a normal claim when this
// same turn resolves the contest AND the gainer is one of the contesting
// parties (a party re-claim); otherwise the frontline stays contested for
// the contested layer (later in the turn) to decide its fate.
const contestedBefore = new Set((state.contested ?? []).map((c) => c.regionKey));
const resolveThisTurn = new Set(result.contested.resolve);
const contestedParties = new Map(
(state.contested ?? []).map((c) => [c.regionKey, c.parties]),
);
for (const [key, claiming] of claimants) { for (const [key, claiming] of claimants) {
if (claiming.size !== 1) continue; // double claim: the key stays as it was if (claiming.size !== 1) continue; // double claim: the key stays as it was
const gainer = [...claiming][0]; const gainer = [...claiming][0];
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt) const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
if (owner === '') continue; // pre-existing double ownership: never touched if (owner === '') continue; // pre-existing double ownership: never touched
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
// (v0.44.0, #66) a currently-contested frontline is not a normal claim
// target: a claim on it only proceeds when this turn resolves the contest
// and the gainer is one of the contesting parties (a party re-claim);
// otherwise the frontline stays contested for the contested layer below.
if (
contestedBefore.has(key) &&
(!resolveThisTurn.has(key) || !contestedParties.get(key)?.includes(gainer))
) {
continue;
}
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a // (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
// different sole owner) only proceeds when the attacker's derived military // different sole owner) only proceeds when the attacker's derived military
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A // is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
@@ -1620,6 +1666,10 @@ export async function runTurn(opts: {
// (v0.43.0, #65) the current sea-zone control (always emitted — even when // (v0.43.0, #65) the current sea-zone control (always emitted — even when
// empty — so the model knows who controls which of the fixed sea zones). // empty — so the model knows who controls which of the fixed sea zones).
state.seaZones ?? [], state.seaZones ?? [],
// (v0.44.0, #66) the current contested regions (war frontlines) — emitted
// verbatim (even when empty) so the model knows which regions are
// frontlines and can resolve them.
state.contested ?? [],
); );
const nudge = const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
+6
View File
@@ -192,6 +192,12 @@ app.post('/api/games', (req: Request, res) => {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // 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 // of all its sub-regions, so ownership (and the choropleth) is at region
+6
View File
@@ -61,6 +61,12 @@ const state: GameState = {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
// The RegionMap built from the fixture: the authoritative region key → owner source. // The RegionMap built from the fixture: the authoritative region key → owner source.
+6
View File
@@ -50,6 +50,12 @@ function mkState(
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats']; if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
+6
View File
@@ -120,6 +120,12 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58); expect(allTerritoryKeys(state).size).toBe(58);
+15 -2
View File
@@ -108,6 +108,13 @@ export interface StubPayload {
* deterministicTurn), so the sea path is covered by unit tests only. * deterministicTurn), so the sea path is covered by unit tests only.
*/ */
seaZones?: Array<{ zone: string; by: string }>; 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 { function truncate(s: string, n: number): string {
@@ -193,6 +200,11 @@ function truncate(s: string, n: number): string {
* (v0.43.0, #65) The stub likewise NEVER claims, contests or re-claims a sea * (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 * zone (it always returns `sea: { control: [] }`) — the sea-control path is
* covered by unit tests only, keeping the e2e assertions untouched. * 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 { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -272,13 +284,14 @@ export function deterministicTurn(p: StubPayload): TurnResult {
nationOps: [], // the stub never proposes nation ops (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment)
}; };
// The 11 ability fields, filled by the registry in its canonical order. The // 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 // 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 // (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the // e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output // historical key order pinned by the stub's deterministic output
// (takeovers → wars → hybrid → economy → industry → nuclear → leaders → // (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology → disasters → migration → sea), which must stay byte-identical. // ideology → disasters → migration → sea → contested), which must stay
// byte-identical.
const abilities = {} as Record<AbilityField, unknown>; const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub(); for (const a of ABILITIES) abilities[a.field] = a.stub();
+204
View File
@@ -783,6 +783,155 @@ export interface SeaOps {
control: SeaControlOp[]; 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[];
}
// --- Alliances / mutual-defense treaties (v0.45.0, #80) ----------------------
//
// A durable, symmetric pact between two distinct nations. The LLM proposes
// `form` / `break` ops each turn; the engine applies them directly
// (`applyTurnResult` → the alliances ability) and persists the active pacts in
// `GameState.alliances`. A pair holds at most one pact (re-forming is a
// no-op), and an active war between the pair overrides the `ally` relation —
// the pact still exists but the `war` relation stays in force until the war
// ends.
/** One active alliance: which two nations are bound, and when the pact was formed. */
export interface Alliance {
/** Stable id (`al1`, `al2`, …) assigned by the engine on formation. */
id: string;
/** The two party nation ids (sorted, distinct). */
parties: string[];
/** The turn number the pact was formed on. */
formedTurn: number;
/** The in-game date the pact took effect on (`YYYY-MM-DD`). */
formedDate: string;
}
/** A single "form a new alliance between a and b" op. */
export interface AllianceFormOp {
/** One party (a nation id or name, resolved via `resolveNationRef`). */
a: string;
/** The other party (a nation id or name, distinct from `a`). */
b: string;
}
/** A single "break the alliance between a and b" op. */
export interface AllianceBreakOp {
/** One party (a nation id or name). */
a: string;
/** The other party (a nation id or name). */
b: string;
}
/** The alliances block of a `TurnResult`. Required; default `{ form: [], break: [] }`. */
export interface AllianceOps {
/** New pacts to form this turn. */
form: AllianceFormOp[];
/** Existing pacts to end this turn. */
break: AllianceBreakOp[];
}
// --- Space program / satellites (v0.45.0, #81) -------------------------------
//
// Durable per-turn space ops: the LLM launches satellites over regions it
// owns and deorbits active ones by id. Each active satellite grants its owner
// a small deterministic +1 stability per committed turn (mirrored to its
// regions, re-derived), stacked per satellite. Launches/deorbits never move
// borders — sole ownership is untouched.
/** The kind of satellite a nation can put into orbit. */
export type SatelliteType = 'recon' | 'comms' | 'navigation' | 'weather';
/** All satellite types, in canonical order (the LLM contract + validator). */
export const SATELLITE_TYPES: SatelliteType[] = ['recon', 'comms', 'navigation', 'weather'];
/** True when `t` is one of the four known satellite types. */
export function isSatelliteType(t: unknown): t is SatelliteType {
return typeof t === 'string' && SATELLITE_TYPES.includes(t as SatelliteType);
}
/** A single "launch a satellite" op: `by` puts a satellite of `type` over `regionKey` (a region it owns). */
export interface SatelliteLaunchOp {
/** The launching nation (a nation id or name, resolved via `resolveNationRef`). */
by: string;
/** The region key the satellite orbits over (must be a valid key owned by `by`). */
regionKey: string;
/** The satellite's type (one of `SATELLITE_TYPES`). */
type: SatelliteType;
}
/** A single "deorbit a satellite" op: the satellite's stable id. */
export type SatelliteDeorbitOp = string;
/** The space block of a `TurnResult`. Required; default `{ launch: [], deorbit: [] }`. */
export interface SpaceOps {
/** New satellites to launch this turn. */
launch: SatelliteLaunchOp[];
/** Satellite ids to deorbit (remove) this turn. */
deorbit: SatelliteDeorbitOp[];
}
/** One active satellite: who launched it, over which region, of what type, and when. */
export interface Satellite {
/** Stable id (`s1`, `s2`, …) assigned by the engine on launch. */
id: string;
/** The launching nation's id (resolved). */
by: string;
/** The region key the satellite orbits over. */
regionKey: string;
/** The satellite's type (one of `SATELLITE_TYPES`). */
type: SatelliteType;
/** The turn number the satellite was launched on. */
launchedTurn: number;
/** The in-game date the satellite was launched on (`YYYY-MM-DD`). */
launchedDate: string;
}
/** The exact JSON object the world-simulator LLM must return for a turn. */ /** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult { export interface TurnResult {
date: string; date: string;
@@ -992,6 +1141,40 @@ export interface TurnResult {
* `{ control: [] }`. * `{ control: [] }`.
*/ */
sea: SeaOps; 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.45.0, #80) Alliances / mutual-defense treaties this turn: `form`
* declares a new pact between two nations (a symmetric alliance entry plus
* an `ally` relation, unless they are at war — the pact still forms but the
* `war` relation keeps overriding); `break` ends an existing pact and sets
* the pair back to `neutral` (unless an active war still binds them). A pair
* holds at most one pact (re-forming is a no-op). Required;
* default `{ form: [], break: [] }`.
*/
alliances: AllianceOps;
/**
* (v0.45.0, #81) Space program / satellites this turn: `launch` puts a new
* satellite (one of the four `SatelliteType`s) into orbit over a region the
* acting nation owns, stamped with the turn and date; `deorbit` removes an
* active satellite by its stable id. Every satellite its owner holds grants
* the owner a small deterministic +1 stability per committed turn (mirrored
* to its regions, re-derived), so a nation with several satellites stacks
* the bonus. Launches/deorbits never move borders — sole ownership is
* untouched. Required; default `{ launch: [], deorbit: [] }`.
*/
space: SpaceOps;
/** /**
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (v0.27, #44) Map-marker ops this turn. `add` places new named markers
* (the engine positions each at its region's centroid + a small * (the engine positions each at its region's centroid + a small
@@ -1233,6 +1416,27 @@ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */ /** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)'; 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. */ /** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string { export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner'; return isNoMansLand ? "No man's land" : 'No owner';
+6
View File
@@ -463,6 +463,12 @@ export function worldDraftToGameState(
industry: [], industry: [],
// (v0.43.0, #65) no sea-zone control on a fresh state. // (v0.43.0, #65) no sea-zone control on a fresh state.
seaZones: [], seaZones: [],
// (v0.44.0, #66) no contested regions on a fresh state.
contested: [],
// (v0.45.0, #80) no alliances on a fresh state.
alliances: [],
// (v0.45.0, #81) no satellites on a fresh state.
satellites: [],
}; };
return state; return state;
} }
+41 -1
View File
@@ -3,7 +3,12 @@
// (The single `import type` below erases at compile time, so no runtime // (The single `import type` below erases at compile time, so no runtime
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps // dependency on protocol.ts is introduced — see the GameState.pendingNationOps
// doc comment.) // doc comment.)
import type { NationOp } from './protocol'; import type {
Alliance,
NationOp,
ContestedRegion,
Satellite,
} from './protocol';
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */ /** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
export interface NationStats { export interface NationStats {
@@ -474,6 +479,41 @@ export interface GameState {
* server/db.ts) — additive, no data wipe. * server/db.ts) — additive, no data wipe.
*/ */
seaZones: SeaZoneClaim[]; 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[];
/**
* (v0.45.0, #80) Active alliances / mutual-defense treaties (see {@link
* Alliance}): one entry per pact — which two nations are bound and when the
* pact was formed. Declared/ended by a turn's `alliances.form` /
* `alliances.break` ops (a `form` op also sets the pair's relation to
* `ally` unless an active war overrides it; a `break` op sets it back to
* `neutral` unless an active war still binds them). A pair holds at most one
* pact (re-forming is a no-op). Always `[]` on a fresh game; rows saved
* before v0.45 load with this defaulted to `[]` (see server/db.ts) —
* additive, no data wipe.
*/
alliances: Alliance[];
/**
* (v0.45.0, #81) Active satellites (see {@link Satellite}): one entry per
* satellite in orbit — which nation launched it, over which region, of what
* type, and when. Declared/deorbited by a turn's `space.launch` /
* `space.deorbit` ops (a launch requires `by` to own `regionKey`; each
* active satellite grants its owner +1 stability per committed turn,
* mirrored to its regions and re-derived). Always `[]` on a fresh game;
* rows saved before v0.45 load with this defaulted to `[]` (see
* server/db.ts) — additive, no data wipe.
*/
satellites: Satellite[];
} }
/** /**
+1
View File
@@ -834,6 +834,7 @@ function GameScreen({
unowned={game.unowned ?? []} unowned={game.unowned ?? []}
markers={game.markers ?? []} markers={game.markers ?? []}
regionStats={game.regionStats ?? {}} regionStats={game.regionStats ?? {}}
contested={game.contested ?? []}
onFocusNation={focusNation} 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'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl); maplibregl.setWorkerUrl(workerUrl);
import { import {
CONTESTED_FILL,
CONTESTED_STROKE,
contestedTooltipLabel,
mapFeatureOwner, mapFeatureOwner,
MARKER_TYPE_INFO, MARKER_TYPE_INFO,
nationColor, nationColor,
regionFillStyle, regionFillStyle,
unownedTooltipLabel, unownedTooltipLabel,
type ContestedRegion,
type RegionMap, type RegionMap,
type RegionOwner, type RegionOwner,
} from '@shared/protocol'; } 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 man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint * (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract. * 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( function applyRegions(
geo: FeatureCollection, geo: FeatureCollection,
@@ -153,6 +163,7 @@ function applyRegions(
playerNationId: string, playerNationId: string,
idKey: Record<string, string>, idKey: Record<string, string>,
unownedSet: ReadonlySet<string>, unownedSet: ReadonlySet<string>,
contestedSet: ReadonlySet<string>,
): FeatureCollection { ): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) { 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. // key the RegionMap by it — never the sub-region id or the repacked id.
const key = idKey[subId] ?? ''; const key = idKey[subId] ?? '';
f.properties.regionKey = key; f.properties.regionKey = key;
const r = key ? regions[key] : undefined; // (v0.44.0, #66) A contested frontline takes precedence over every other
if (r) { // state (owned, unowned, no man's land): the key is in no nation's
const style = regionFillStyle(r, false); // territory and not no man's land, so it renders the distinct contested
f.properties.fill = style.fill; // fill/stroke (decided by the client from `GameState.contested`, never by
f.properties.stroke = style.stroke; // the RegionMap, which has no entry for it).
f.properties.owner = r.owner; if (key !== '' && contestedSet.has(key)) {
f.properties.ownerId = r.ownerId; f.properties.fill = CONTESTED_FILL;
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; f.properties.stroke = CONTESTED_STROKE;
} 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.owner = '';
f.properties.ownerId = ''; f.properties.ownerId = '';
f.properties.isPlayer = 0; 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; return out;
@@ -412,8 +436,14 @@ function hoverHtml(
noMansLand = false, noMansLand = false,
regionKey?: string, regionKey?: string,
regionStats?: Record<string, RegionStats>, regionStats?: Record<string, RegionStats>,
contestedParties?: string[],
): string { ): string {
if (!region || !nation) { 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>`; 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 // (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
@@ -479,6 +509,15 @@ interface Props {
* stats. * stats.
*/ */
regionStats?: Record<string, RegionStats>; 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, onFocusNation,
markers, markers,
regionStats, regionStats,
contested,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@@ -553,6 +593,10 @@ export default function WorldMap({
const markersRef = useRef<MapMarker[]>(markers ?? []); const markersRef = useRef<MapMarker[]>(markers ?? []);
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip. // (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {}); 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; regionsRef.current = regions;
nationsRef.current = nations; nationsRef.current = nations;
playerRef.current = playerNationId; playerRef.current = playerNationId;
@@ -561,6 +605,11 @@ export default function WorldMap({
onFocusNationRef.current = onFocusNation; onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? []; markersRef.current = markers ?? [];
regionStatsRef.current = regionStats ?? {}; 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 // Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes). // so a resync with identical data never re-flashes).
@@ -721,9 +770,12 @@ export default function WorldMap({
const pKey = f.properties?.regionKey; const pKey = f.properties?.regionKey;
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined; const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr); 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 hoverPopup
.setLngLat(e.lngLat) .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); .addTo(map);
map.getCanvas().style.cursor = 'pointer'; map.getCanvas().style.cursor = 'pointer';
}; };
@@ -799,7 +851,7 @@ export default function WorldMap({
const idKey = idKeyRef.current; const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !idKey || !src) return; 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). // Stamp the current markers onto the marker source (v0.27, #44).
@@ -977,7 +1029,7 @@ export default function WorldMap({
if (map && geo && idKey) { if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; 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) { if (wasPrimed && prev && idKey) {
@@ -989,7 +1041,9 @@ export default function WorldMap({
// up as ownerId changes above, so they flash like any other transfer. // up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]); 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 // (v0.27, #44) Repaint the marker symbol layer whenever the marker list
// changes (a turn placed/removed markers) or the nations record changes // changes (a turn placed/removed markers) or the nations record changes