Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9fd6a09d | ||
|
|
b5ea8ba5a4 | ||
|
|
ed91735c8a | ||
|
|
8eeb6df284 | ||
|
|
f45c01b8d6 | ||
|
|
b5e2229ee2 | ||
|
|
38d3202472 |
@@ -56,7 +56,7 @@ history/
|
||||
├── eslint.config.js # flat config (ESLint 9)
|
||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 225 tests)
|
||||
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 407 tests)
|
||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||
├── index.html
|
||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||
@@ -327,6 +327,86 @@ history/
|
||||
`server/protocol.test.ts`, `server/db.test.ts`, `server/regions.test.ts`,
|
||||
`scripts/region-geo.test.ts`). **PR 1 of 2 — deliberately NOT deployed** (the user deploys
|
||||
when PR 2, the per-region LLM lever, ships).
|
||||
- **Wars — `GameState.wars`, a first-class conflict entity (v0.33, #56).**
|
||||
Since v0.33 the `war` relation is no longer a bare boolean flag but a named `War` entity in
|
||||
`GameState.wars` (required, `[]` at creation; a legacy SQLite row without the field loads as
|
||||
`[]` — additive, never wipes). Each `War` has a stable id (`w1`, `w2`, …), a `sides` list
|
||||
(≥2 distinct nation ids — any two distinct members are at war with each other), `declaredTurn`,
|
||||
`declaredDate`, optional `aims`, a `status` (`active` / `armistice` / `capitulation`), and an
|
||||
optional `front` (region keys). `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }`
|
||||
(both required, default `[]`): a **declare** creates a new active `War` (sides resolved via
|
||||
`resolveNationRef` — id first, then case-insensitive unique name; a declare with <2 resolved
|
||||
distinct sides is dropped); an **end** finds a war by id (unknown → no-op, never throw) and sets
|
||||
its `status` to the outcome (`armistice` or `capitulation`). After declare/end,
|
||||
`syncWarRelations(prev, next)` enforces the derived `war` relation: any two nations on opposite
|
||||
sides of ≥1 **active** war get `war` in both directions (overriding lesser statuses — you
|
||||
cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not
|
||||
covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching
|
||||
active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense
|
||||
drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the
|
||||
war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted
|
||||
id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations
|
||||
per turn, each narrated in `events`); `buildUserPrompt` carries the current **active** wars
|
||||
(as `WarInfo[]`: id + sides + aims) so the model can `end` by id. The **stub never declares or
|
||||
ends** a war (`wars: { declare: [], end: [] }`) — e2e (CI, `LLM_STUB=1`) is unaffected and the
|
||||
path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Hybrid war — `TurnResult.hybrid`, a deterministic non-kinetic operations spectrum (v0.34, #57).**
|
||||
`TurnResult.hybrid = { ops: HybridOp[] }` (required; default `{ ops: [] }`) adds a covert /
|
||||
hybrid-warfare spectrum alongside the kinetic `regionChanges` lever: `HybridOp =
|
||||
{ type, from, target, detail?, outcome? }` with `type ∈ spy | sabotage | assassinate |
|
||||
cyber | economic | information | proxy` and `outcome ∈ success | exposed` (the LLM
|
||||
declares its *intended* outcome; the engine applies the deterministic effect).
|
||||
`from`/`target` are refs resolved by the shared `resolveNationRef` (id first, then
|
||||
case-insensitive unique name); a `target` that is not a resolvable nation is treated
|
||||
as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the
|
||||
acting nation itself — is dropped, never thrown). Per type: `sabotage` dips `economy`
|
||||
(−3), `economic` dips `economy` (−5), `cyber` dips `military` (−3), `assassinate` dips
|
||||
`stability` (−5); `information` nudges the from↔target relation one step apart (capped
|
||||
at `hostile`), `proxy` likewise but capped at `rival`; `spy` is informational only.
|
||||
A region-target dip lands on the single `regionStats[regionKey]` entry (hybrid ops
|
||||
never move territory — `regionChanges` is the only lever that does); a nation-target
|
||||
dip lands on every region the nation owns; afterwards the target's derived `stats` is
|
||||
re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership
|
||||
invariant is untouched. **Exposed** (`outcome: "exposed"`) is a backfire: the intended
|
||||
effect is NOT applied; instead the from↔target relation moves one step *toward*
|
||||
`hostile` (capped; `war` is never created or escalated by an exposed op) and a
|
||||
diplomatic/failed event is auto-appended to the turn's `events`.
|
||||
**Additive only — no `GameState.hybrid` field, no `db.ts` change** (the ops are
|
||||
consumed inside `applyTurnResult` after territory + wars and before markers).
|
||||
`isMeaningful` counts hybrid ops; the system prompt documents the field + per-type
|
||||
effects (the "unclear" fallback and the stub both return `hybrid: { ops: [] }`), and the
|
||||
user payload carries the player's `relations` map so the model can reason about the
|
||||
relation caps. The **stub never** proposes a hybrid op, so e2e (CI, `LLM_STUB=1`) is
|
||||
unaffected and the path is unit-tested only (`server/engine.test.ts`).
|
||||
- **Trade & embargo economy layer — `GameState.trades` / `GameState.embargoes`, `TurnResult.economy` (v0.35, #58).**
|
||||
`TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required;
|
||||
default `{ trade: [], embargo: [] }`) is the model's lever for the economic layer: a
|
||||
bilateral **trade** (`{ a, b, lift? }` — symmetric, stored **sorted** `a < b`) and a
|
||||
unilateral **embargo** (`{ from, target, lift? }` — ordered). No `lift` declares/imposes,
|
||||
`lift: true` ends/lifts (a no-op when absent). Refs are resolved at apply time by the
|
||||
shared `resolveNationRef` (id first, then case-insensitive unique name); an unknown /
|
||||
ambiguous / self-pairing op is dropped (never thrown). The lists live in `GameState`
|
||||
(`trades`/`embargoes`, required, `[]` at creation; a legacy SQLite row without them loads
|
||||
as `[]` — additive, never wiped, like `wars`/`markers`). **Deterministic, no hidden
|
||||
randomness.** Every committed turn, in `applyTurnResult` (after hybrid, before markers):
|
||||
(1) `applyEconomyOps` applies this turn's declares/lifts to the active lists (idempotent
|
||||
declares — an already-active pair/ordered pair is not duplicated); (2)
|
||||
`suspendEconomyOnWar` **auto-suspends** any trade or embargo whose two nations are on
|
||||
opposite sides of an **active** `War` (#56) — a war severs the economic link, so the
|
||||
suspended pair stops producing its effect (this also drops a trade declared this turn
|
||||
between two nations that just went to war); (3) `applyEconomyEffects` applies the per-turn
|
||||
effect to the **full** active lists: each active trade gives **both** parties a small,
|
||||
deterministic `economy` gain (`TRADE_ECONOMY_BONUS`), each active embargo dips the
|
||||
**target's** `economy` only (`EMBARGO_ECONOMY_PENALTY`) — clamped to 0–100, mirrored to
|
||||
every region the nation owns (`regionStats`), and each affected nation's derived `stats`
|
||||
re-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership
|
||||
is untouched (the layer moves stats, never territory). `isMeaningful` counts non-empty
|
||||
`economy.trade` / `economy.embargo`; the system prompt documents the field + constraints
|
||||
(at most 1–2 ops per turn, narrated in `events`; warring pairs auto-suspended), and the
|
||||
user payload carries the active `trades`/`embargoes` so the model can `lift` them. The
|
||||
**stub never** declares or lifts a trade/embargo (`economy: { trade: [], embargo: [] }`),
|
||||
so e2e (CI, `LLM_STUB=1`) is unaffected and the path is unit-tested only
|
||||
(`server/engine.test.ts`).
|
||||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.35.0 — 2026-10-15 17:22
|
||||
- **#58: Trade & Embargoes — a bilateral trade + embargo economy layer with a deterministic per-turn effect and war auto-suspension.** Every prior turn lever moved borders, dipped stats one-shot, or proposed a player-confirmed change. This version adds `TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }` (required, default `{ trade: [], embargo: [] }`) so the model can declare/lift **bilateral trade agreements** and **unilateral embargoes**, which persist in `GameState` and apply a small, deterministic economic effect **every committed turn**. The engine is **fully deterministic (no hidden randomness)** and **additive** — declares add to the lists, lifts remove, and nothing else (not even a war, beyond the suspension below) mutates them.
|
||||
- **Types (`shared/types.ts`).** New `TradeAgreement` (`{ a; b }` — a symmetric pair, stored sorted `a < b`) and `Embargo` (`{ from; target }` — an ordered unilateral pair) interfaces, plus the required `GameState.trades` and `GameState.embargoes` fields (placed after `wars`). Both are `[]` on a fresh game; rows saved before v0.35 load with them defaulted to `[]` (additive, never wiped).
|
||||
- **Protocol (`shared/protocol.ts`).** New `EconomyTradeOp` (`{ a; b; lift? }`), `EconomyEmbargoOp` (`{ from; target; lift? }`), and `EconomyOps` (`{ trade; embargo }`) types. `TurnResult.economy` is required, placed between `hybrid` and `markers`; a no-`lift` op declares/imposes, `lift: true` ends/lifts.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateEconomyOps(raw)` — new exported, defensive validator (never throws): a missing/malformed object yields `{ trade: [], embargo: [] }`; each op needs non-empty `a`/`b` (or `from`/`target`) refs (trimmed, ≤200 chars) and a boolean `lift` (default false). Raw refs are kept — resolution happens at apply time against the real state (like wars/hybrid ops).
|
||||
- `applyEconomyOps(next, ops)` — internal; resolves each op's refs via `resolveNationRef` (id first, then unique case-insensitive name). An unresolvable / ambiguous / self-pairing op is dropped (never thrown). A no-`lift` trade **declares** the agreement idempotently (stored sorted, so `{a,b}` and `{b,a}` are the same pair); `lift: true` ends it (a no-op when absent). A no-`lift` embargo **imposes** the ordered from→target pair idempotently; `lift: true` lifts it.
|
||||
- `suspendEconomyOnWar(next)` — internal; any active trade or embargo whose two nations are on **opposite sides of an active war** (v0.33, #56) is removed — a war severs the economic link, so the suspended agreement stops producing its per-turn effect from now on. Runs **after** the turn's war declare/end ops (so it sees the final `next.wars`, including a war declared this turn) and **before** the per-turn effect.
|
||||
- `applyEconomyEffects(next, geoOf?)` — internal; the per-turn effect, applied **every committed turn** to the **full** active lists (not just this turn's declares): each active trade grants **both** parties a small, deterministic economy gain (`TRADE_ECONOMY_BONUS`); each active embargo applies a small, deterministic economy dip to the **target only** (`EMBARGO_ECONOMY_PENALTY`). Clamped to 0–100, mirrored to every region the nation owns (`regionStats`), and each affected nation's derived `stats` re-derived afterwards (same 0-region/no-entry guards as the hybrid dip). No-op when there are no active trades or embargoes; never throws.
|
||||
- `applyTurnResult` — new **economy block** (after the hybrid block, before markers): `applyEconomyOps` → `suspendEconomyOnWar` → `applyEconomyEffects`, in that order (ops first, so a same-turn trade declared between two nations that just went to war is suspended; effects then run on the surviving full lists). Sole ownership is untouched (the economy layer moves stats, never territory).
|
||||
- `isMeaningful` — a non-empty `economy.trade` or `economy.embargo` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `economy` (default `{ trade: [], embargo: [] }`).
|
||||
- `buildSystemPrompt` — documents the `economy` field and a constraint bullet (bilateral trade gains both parties; a unilateral embargo hits the target only; omit `lift` to declare/impose, `lift: true` to end/lift; at most 1–2 ops per turn, narrated in `events`; trades/embargoes between warring nations are auto-suspended) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `trades` and `embargoes` params (the currently active agreements/embargoes) so the model knows what is active and can `lift` it; emitted only when provided.
|
||||
- `runTurn` — passes `state.trades` and `state.embargoes` to `buildUserPrompt`; the "unclear" fallback carries `economy: { trade: [], embargo: [] }`.
|
||||
- **Stub (`server/stub.ts`).** The deterministic stub returns `economy: { trade: [], embargo: [] }` — it **never** declares or lifts a trade or embargo, so the e2e (CI, `LLM_STUB=1`) assertions are unaffected and the path is covered by unit tests only (`server/engine.test.ts`).
|
||||
- **Persistence (`server/db.ts`).** `load()` defaults `trades`/`embargoes` to `[]` on rows saved before v0.35 (additive migration, alongside `unowned`/`pendingNationOps`/`markers`/`wars`) — legacy games are never wiped and never gain phantom trades/embargoes.
|
||||
- **Game creation (`server/index.ts`, `shared/scenario.ts`).** Fresh games (preset and dynamic) are created with `trades: []` and `embargoes: []`.
|
||||
|
||||
## v0.34.0 — 2026-10-15 16:35
|
||||
- **#57: Espionage & Hybrid War — a non-kinetic operations spectrum with a deterministic per-type effect and an exposure backfire.** Every prior turn lever was kinetic (conquest, state-change, region-change, wars, takeovers) or a player-confirmed proposal (nation ops). This version adds `TurnResult.hybrid = { ops: HybridOp[] }` (required, default `{ ops: [] }`) so the model can run **covert, non-kinetic** operations: spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory these **never move borders** — they dip a target's stats or sour a relation. The engine is **fully deterministic (no hidden randomness)**: the LLM declares an intended `outcome` (`success` by default, or `exposed`); a successful op applies its small per-type effect, an `exposed` op **backfires** (no effect, the actor's relation with the target sours one step toward hostile, and an auto `diplomatic`/`failure` event is emitted).
|
||||
- **Protocol (`shared/protocol.ts`).** New `HybridOpType` (the seven types) + `HYBRID_OP_TYPES` const, and the `HybridOp` interface (`{ type; from; target; detail?; outcome? }`). `from` is the acting nation (id or unique name — resolved at apply time); `target` is **either** a nation (id/name) **or** a region key (hits that region's sole owner). `TurnResult.hybrid = { ops: HybridOp[] }` is required, placed between `wars` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `isHybridOpType(t)` — exported guard over the seven types.
|
||||
- `validateHybridOps(raw)` — new exported, defensive validator (never throws): accepts the `{ ops: […] }` object **or** a bare array; each op needs a known `type`, a non-empty `from` (≤200) and non-empty `target` (≤200); `outcome:'exposed'` is kept (success/missing is omitted to keep the shape minimal); `detail` capped at 300.
|
||||
- `HYBRID_STAT_EFFECT` — exported table of the deterministic per-type stat dip: `sabotage`→economy −3, `assassinate`→stability −5, `cyber`→military −3, `economic`→economy −5; `spy`/`information`/`proxy` have no stat effect.
|
||||
- `shiftRelationTowardHostile(state, a, b, cap)` — exported; nudges the bilateral relation **one** rung up the hostile ladder (`ally→neutral→rival→hostile`), symmetrically, capped at `cap`; a missing relation starts at `neutral`; a `war` relation is never touched (a covert op neither fabricates nor downgrades a war). Used for the `information`/`proxy` nudge and the `exposed` backfire.
|
||||
- `resolveHybridTarget(state, target, validKeys?)` — internal; resolves a target to the concrete nation it affects: a nation ref via `resolveNationRef`, else a region key's **sole** owner (0 or 2+ owners → not targetable); `validKeys` (when supplied) gates the region path.
|
||||
- `applyHybridOp(next, op, geoOf?, validKeys?, regionNameOf?, actingTurn?)` — internal; resolves `from`/`target` (unknown/ambiguous actor, unresolvable target, and self-targets are dropped — never throw), then applies the per-type effect: an `exposed` op backfires (relation sours one step, cap hostile, auto `diplomatic`/`failure` event); a successful stat op dips the target's `regionStats` (the single named region for a region target, otherwise **every** region the target owns) and re-derives the target nation's aggregate stats (same 0-region/no-entry guards as the main recompute) so the effect persists; `information`/`proxy` nudge the relation (caps `hostile`/`rival`); `spy` is informational only.
|
||||
- `applyTurnResult` — new **hybrid block** (after the wars block, before markers): applies each validated op against the post-wars state. Sole ownership is preserved (a hybrid op affects stats, never ownership).
|
||||
- `isMeaningful` — a non-empty `hybrid.ops` makes a turn meaningful.
|
||||
- `validateTurnResult` — initializes and validates `hybrid` (default `{ ops: [] }`).
|
||||
- `buildSystemPrompt` — documents the `hybrid` field and a HARD CONSTRAINTS bullet (non-kinetic, never moves borders, the per-type effects, the `exposed` backfire, region-key targets hit the sole owner, at most a few per turn, narrate each in `events`) in both tool and plain modes.
|
||||
- `buildUserPrompt` — new optional `relations` param (the player's `nationId → RelationStatus` map) so the model sees the ladder its ops nudge and the starting point before a backfire; emitted only when provided.
|
||||
- `runTurn` — computes the player's `relations` from state and passes them as the last `buildUserPrompt` arg; the "unclear" fallback carries `hybrid: { ops: [] }`.
|
||||
- **Additive only.** No `GameState` field, **no** DB migration, **no** `db.ts` change — `hybrid` lives entirely in the `TurnResult` and is consumed by `applyTurnResult`.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `hybrid: { ops: [] }` — the stub **never** runs a covert op (mirrors `nationOps: []`, `takeovers: []`, `wars`). `StubPayload` gains an optional `relations` field (ignored). E2E (CI, `LLM_STUB=1`) is fully unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `isHybridOpType`; `validateHybridOps` (both raw shapes, trim/cap, exposed-vs-success, drop rules); `validateTurnResult` — hybrid; `isMeaningful` — hybrid; `shiftRelationTowardHostile` (ladder, caps, war-untouched, missing=neutral); `applyTurnResult` — hybrid (each stat type's dip, nation-vs-region target, the per-type relation nudge & caps, `exposed` backfire + auto event, exposed-on-war, drop rules for unknown actor / self-target / unknown region / double-owned region, the `validKeys` gate, borders-untouched invariant, `HYBRID_STAT_EFFECT` magnitudes); `buildSystemPrompt` — hybrid contract (both modes); `buildUserPrompt` — relations; stub — hybrid.
|
||||
- **Client.** No change — hybrid ops are an engine/LLM concern; their effects surface through the existing stats, relations, and event feed.
|
||||
- **AGENTS.md.** New convention entry documenting the `hybrid` field, the deterministic per-type effects, the `exposed` backfire, the region-vs-nation targeting, and the stub/test coverage.
|
||||
|
||||
## v0.33.0 — 2026-10-15 15:45
|
||||
- **#56: War as a first-class state — the `war` relation becomes a named `War` entity.** The v0.22+ `war` relation was a bare boolean flag on the `Relation` type with no lifecycle, no identity, and no way to distinguish "at war since 1939" from "at war since 1941" or to narrate the end of a conflict. This version promotes it to a first-class `War` entity in `GameState.wars` with a stable id, sides, declaration date/turn, optional aims, a lifecycle status (`active` / `armistice` / `capitulation`), and an optional front (region keys). The LLM declares and ends wars via `TurnResult.wars = { declare, end }` (both required, default `[]`); the engine creates/updates the entity and **derives** the `war` relation from active wars (any two nations on opposite sides of ≥1 active war get `war` in both directions, overriding lesser statuses — you cannot "make peace" without ending the war). When a war ends, the pairs it caused that are not covered by another active war fall back to `hostile`. A **bare** `war` relation with no matching active war (legacy/back-compat) is never downgraded and never force-created. **Mutual-defense drag-in**: when a war is declared, each belligerent's `ally`-relation partners are added to the war's `sides` (one level, no cascade; deterministic — sides in declared order, allies in sorted id order), so the dragged-in ally becomes `war` with all belligerents including its former ally.
|
||||
- **Types (`shared/types.ts`).** New `War` interface (`id`, `sides: string[]` ≥2 distinct, `declaredTurn: number`, `declaredDate?: string`, `aims?: string[]`, `status: 'active'|'armistice'|'capitulation'`, `front?: string[]`); `GameState.wars: War[]` (required, `[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||
- **Protocol (`shared/protocol.ts`).** New `WarDeclareOp` (`{ sides: string[]; aims?: string[] }`) and `WarEndOp` (`{ id: string; outcome: 'armistice'|'capitulation'; terms?: string }`); `TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }` (both required, default `[]`). New `WarInfo` interface (`{ id; sides; aims? }`) for the user-prompt payload.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateWarOps(raw)` — new exported, defensive validator (never throws): non-object → empty; `declare` — sides trimmed/capped 200/deduped, ≥2; aims ≤5×200; `end` — id ≤200, outcome exactly `armistice`|`capitulation`, terms ≤400.
|
||||
- `activeWarPairs(wars): Set<string>` — NUL-joined sorted pair keys for active-only wars.
|
||||
- `atWar(wars, a, b)` — convenience check.
|
||||
- `syncWarRelations(prev, next)` — exported; enforces the derived `war` relation (active pairs → `war` both directions; ended pairs not covered by another active war → `hostile`; bare `war` never downgraded, never force-created).
|
||||
- `freshWarId(taken)` — first free of `w1`, `w2`, … (next to `freshNationId`).
|
||||
- `applyTurnResult` — new **wars block** (after takeovers, before markers): declare → create `War` (sides resolved via `resolveNationRef`; <2 distinct → dropped); end → find by id (unknown → no-op); then `syncWarRelations(state, next)`.
|
||||
- `isMeaningful` — a non-empty `declare` or `end` makes a turn meaningful.
|
||||
- `buildSystemPrompt` — documents the `wars` field (declare/end, at most 1–2 per turn, each narrated in `events`; the `war` relation is derived — do not emit it directly for active-war pairs).
|
||||
- `buildUserPrompt` — carries current **active** wars as `WarInfo[]` so the model can `end` by id.
|
||||
- `runTurn` — computes active-only `wars` from `state.wars`, passes as last arg; the "unclear" fallback carries `wars: { declare: [], end: [] }`.
|
||||
- **DB (`server/db.ts`).** `load` defaults `state.wars` to `[]` when absent (after the markers default, before `migrateRegionStats`).
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `wars: { declare: [], end: [] }` — the stub never declares or ends a war (mirrors `nationOps: []`, `takeovers: []`). E2E (CI, `LLM_STUB=1`) is unaffected; the path is unit-tested only.
|
||||
- **Unit tests (`server/engine.test.ts`).** ~30 new tests: `freshWarId`, `validateWarOps`, `validateTurnResult` (wars), `activeWarPairs` / `atWar`, `syncWarRelations`, `applyTurnResult` — wars (declare creates war + derives relations; end removes active status + un-derives relations; mutual-defense drag-in; unknown-id end no-op; duplicate declare skip; unresolvable-ref drop; bare-war back-compat; relations-override-forced-back; same-turn declare+end leaves prior relation untouched), `isMeaningful` — wars, `buildSystemPrompt` — wars contract, `buildUserPrompt` — wars, stub — wars.
|
||||
- **Client.** No change — wars are an engine/LLM concern; the UI already renders the `war` relation from the `Relation` type (which the engine now keeps in sync).
|
||||
- **AGENTS.md.** New convention entry documenting the `War` entity, the `syncWarRelations` algorithm, the mutual-defense drag-in, and the stub/test coverage.
|
||||
|
||||
## v0.32.0 — 2026-10-15 10:56
|
||||
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.32.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
|
||||
@@ -31,6 +31,11 @@ function makeState(id: string): GameState {
|
||||
regionStats: {
|
||||
GBR: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
},
|
||||
// (v0.33.0, #56) no wars.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) no active trades or embargoes.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +152,19 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.35.0 #58) a legacy row saved before the trades/embargoes fields existed loads with them defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { trades?: unknown[] }).trades; // a pre-v0.35 save (fields absent)
|
||||
delete (legacy as { embargoes?: unknown[] }).embargoes;
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.trades).toEqual([]);
|
||||
expect(loaded!.embargoes).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
@@ -49,6 +49,14 @@ export function openDb(filePath: string): Db {
|
||||
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||
if (state.markers === undefined) state.markers = [];
|
||||
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
|
||||
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
|
||||
// syncWarRelations, which never force-creates a War entity).
|
||||
if (state.wars === undefined) state.wars = [];
|
||||
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
|
||||
// legacy game has no active trades or embargoes.
|
||||
if (state.trades === undefined) state.trades = [];
|
||||
if (state.embargoes === undefined) state.embargoes = [];
|
||||
// (v0.28, #46) Rows saved before per-region stats existed are migrated
|
||||
// ONCE at load time (and never re-migrated afterwards — the field is
|
||||
// then present): each owned region is seeded with a copy of its owner's
|
||||
|
||||
+1196
-21
File diff suppressed because it is too large
Load Diff
+797
-4
@@ -4,6 +4,7 @@
|
||||
// and (4) builds the advisor brainstorm prompt. The network call itself lives in
|
||||
// llm.ts (injectable) and is invoked by runTurn.
|
||||
import type {
|
||||
Embargo,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
RegionStats,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
TradeAgreement,
|
||||
War,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
@@ -23,12 +26,18 @@ import {
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
HYBRID_OP_TYPES,
|
||||
isDateLike,
|
||||
isMarkerType,
|
||||
makeEvent,
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
nationForPrompt,
|
||||
type EconomyEmbargoOp,
|
||||
type EconomyOps,
|
||||
type EconomyTradeOp,
|
||||
type HybridOp,
|
||||
type HybridOpType,
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
@@ -36,6 +45,9 @@ import {
|
||||
type Takeover,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
type WarDeclareOp,
|
||||
type WarEndOp,
|
||||
type WarInfo,
|
||||
} from '../shared/protocol';
|
||||
import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
@@ -51,6 +63,12 @@ const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
|
||||
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
|
||||
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
|
||||
const VALID_HYBRID_TYPES = new Set<string>(HYBRID_OP_TYPES);
|
||||
|
||||
/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */
|
||||
export function isHybridOpType(t: unknown): t is HybridOpType {
|
||||
return typeof t === 'string' && VALID_HYBRID_TYPES.has(t);
|
||||
}
|
||||
|
||||
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
|
||||
|
||||
@@ -150,7 +168,9 @@ export function playerFrontier(
|
||||
* (v0.21, #16 P2): the model may only move regions across a shared border, and
|
||||
* may verify borders with the tool before answering. The contract also
|
||||
* documents the `nationOps` proposal queue (v0.23, #16 P4a) — rename plus the
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b).
|
||||
* split/merge shapes applied on player confirm (v0.24, #16 P4b), and the
|
||||
* trade & embargo economy layer (v0.35.0, #58): bilateral trades and
|
||||
* unilateral embargoes, each ended with a `lift: true` op.
|
||||
*/
|
||||
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
|
||||
const rules = preset.simulationRules
|
||||
@@ -201,6 +221,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||
' "hybrid": {"ops":[{"type":"<spy|sabotage|assassinate|cyber|economic|information|proxy>","from":"<nation id or name>","target":"<nation id or name, or region key>","detail":"<short detail>","outcome":"<success|exposed>"}]},',
|
||||
' "economy": {"trade":[{"a":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -219,6 +242,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
||||
'- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".',
|
||||
'- Economy ("economy") is the trade & embargo layer: a bilateral trade (trade: {"a":"..","b":".."}) gives BOTH parties a small, deterministic economy gain every turn, while a unilateral embargo (embargo: {"from":"..","target":".."}) lowers only the TARGET\'s economy every turn. Omit "lift" to declare / impose, and set "lift":true to end or lift an existing one (the user message\'s "trades"/"embargoes" fields list what is currently active). Propose at most 1-2 operations per turn and narrate each one in "events". Trades and embargoes between nations at war are suspended automatically by the engine.',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
@@ -236,6 +262,9 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
* other consumer) can read the exact items. The framing instruction tells the
|
||||
* model to simulate the combined consequences of the whole batch (or to let the
|
||||
* world advance on its own when the batch is empty) and return one TurnResult.
|
||||
* (v0.35.0, #58) It also carries the currently active `trades` / `embargoes`
|
||||
* (omitted entirely when undefined, like the other optional fields) so the
|
||||
* model can reference them — and `lift` them — in `TurnResult.economy`.
|
||||
*/
|
||||
export function buildUserPrompt(
|
||||
state: GameState,
|
||||
@@ -263,6 +292,35 @@ export function buildUserPrompt(
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
markers?: MarkerInfo[],
|
||||
/**
|
||||
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
|
||||
* stable id, sides and aims. Emitted verbatim (even when empty) so the
|
||||
* model can reference a war's id in `TurnResult.wars.end` to end it.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
wars?: WarInfo[],
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`). Emitted verbatim (even when empty) so the
|
||||
* model sees the relation ladder it can nudge with `information` / `proxy`
|
||||
* hybrid ops and knows the current state before an `exposed` backfire.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>,
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active bilateral trade agreements (each a
|
||||
* pair of nation ids). Emitted verbatim (even when empty) so the model
|
||||
* knows which trades exist and can end one with `economy.trade` +
|
||||
* `lift: true`. Omitted entirely when undefined.
|
||||
*/
|
||||
trades?: TradeAgreement[],
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active unilateral embargoes (each an
|
||||
* `from` → `target` pair of nation ids). Emitted verbatim (even when
|
||||
* empty) so the model knows which embargoes are in force and can lift one
|
||||
* with `economy.embargo` + `lift: true`. Omitted entirely when undefined.
|
||||
*/
|
||||
embargoes?: Embargo[],
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -375,6 +433,10 @@ export function buildUserPrompt(
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
if (wars !== undefined) payload.wars = wars;
|
||||
if (relations !== undefined) payload.relations = relations;
|
||||
if (trades !== undefined) payload.trades = trades;
|
||||
if (embargoes !== undefined) payload.embargoes = embargoes;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -513,6 +575,9 @@ export function validateTurnResult(
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -607,6 +672,22 @@ export function validateTurnResult(
|
||||
// by applyTurnResult (automatic, not player-confirmed).
|
||||
result.takeovers = validateTakeovers(o.takeovers);
|
||||
|
||||
// (v0.33.0, #56) War declare/end ops: creating and ending named Wars.
|
||||
// Applied directly by applyTurnResult (after takeovers, before markers).
|
||||
result.wars = validateWarOps(o.wars);
|
||||
|
||||
// (v0.34.0, #57) Covert hybrid ops (spies, sabotage, assassination, cyber,
|
||||
// economic, disinformation, proxy). Applied directly by applyTurnResult
|
||||
// (after wars, before markers) — a deterministic per-type effect or an
|
||||
// exposed backfire.
|
||||
result.hybrid = { ops: validateHybridOps(o.hybrid) };
|
||||
|
||||
// (v0.35.0, #58) Trade & embargo ops: a no-`lift` op declares / imposes,
|
||||
// `lift: true` ends / lifts. Refs stay raw here and are resolved at apply
|
||||
// time against the real state (like wars / hybrid ops); unknown /
|
||||
// ambiguous / self-pairing ops are dropped there, never thrown.
|
||||
result.economy = validateEconomyOps(o.economy);
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -736,13 +817,565 @@ export function validateTakeovers(raw: unknown): Takeover[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
|
||||
* `{ declare, end }` shape. Defensive like the rest of the parser — never
|
||||
* throws. `declare`: each entry must be an object whose `sides` is an array
|
||||
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
|
||||
* yields at least TWO distinct sides — anything else (a one-sided or
|
||||
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
|
||||
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
|
||||
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
|
||||
* of exactly `armistice` or `capitulation` (anything else is dropped);
|
||||
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
|
||||
* input yields `{ declare: [], end: [] }`.
|
||||
*/
|
||||
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
|
||||
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.declare)) {
|
||||
for (const e of o.declare) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const d = e as Record<string, unknown>;
|
||||
const sides = (Array.isArray(d.sides) ? d.sides : [])
|
||||
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
|
||||
.filter((s) => s !== '')
|
||||
// dedupe case-insensitively? No — sides are nation ids: keep exact
|
||||
// strings, drop exact duplicates only (resolution is later).
|
||||
.filter((s, i, arr) => arr.indexOf(s) === i);
|
||||
if (sides.length < 2) continue; // a war needs at least two belligerents
|
||||
const aims = Array.isArray(d.aims)
|
||||
? (d.aims as unknown[])
|
||||
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
|
||||
.filter((a) => a !== '')
|
||||
.slice(0, 5)
|
||||
: undefined;
|
||||
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(o.end)) {
|
||||
for (const e of o.end) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const w = e as Record<string, unknown>;
|
||||
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
|
||||
if (!id) continue;
|
||||
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
|
||||
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
|
||||
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe
|
||||
* `{ ops }` shape. Defensive like the rest of the parser — never throws. The
|
||||
* raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare
|
||||
* array (be lenient about either). Each op needs a known `type` (one of
|
||||
* {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at
|
||||
* 200 chars) and a non-empty `target` (a nation id/name or a region key, capped
|
||||
* at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the
|
||||
* default, `success`, is stored as absent to keep the shape minimal — the
|
||||
* engine treats a missing outcome as success); a non-empty `detail` is capped
|
||||
* at 300 chars. Resolution of `from` / `target` to concrete nation ids or
|
||||
* region owners happens at apply time (`applyHybridOp`), not here.
|
||||
*/
|
||||
export function validateHybridOps(raw: unknown): HybridOp[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
// Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []).
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const inner = (raw as Record<string, unknown>).ops;
|
||||
if (!Array.isArray(inner)) return [];
|
||||
return validateHybridOpsList(inner);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return validateHybridOpsList(raw);
|
||||
}
|
||||
|
||||
function validateHybridOpsList(raw: unknown[]): HybridOp[] {
|
||||
const out: HybridOp[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
if (!isHybridOpType(o.type)) continue;
|
||||
const from = str(o.from, 200);
|
||||
const target = str(o.target, 200);
|
||||
if (!from || !target) continue;
|
||||
const detail = str(o.detail, 300);
|
||||
const exposed = o.outcome === 'exposed'; // 'success' / missing → success
|
||||
out.push({
|
||||
type: o.type,
|
||||
from,
|
||||
target,
|
||||
...(detail ? { detail } : {}),
|
||||
...(exposed ? { outcome: 'exposed' as const } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Validate the raw `economy` object of a turn into a safe
|
||||
* {@link EconomyOps}. Defensive like the rest of the parser — never throws.
|
||||
* `trade`: each op needs non-empty `a` and `b` refs (a nation's id or unique
|
||||
* name, capped at 200 chars) and a boolean `lift` (default false = declare);
|
||||
* ops with a missing ref are dropped — ref resolution and the
|
||||
* a≠b / from≠target checks happen at apply time against the real state
|
||||
* (like wars / hybrid ops, whose raw refs are kept here). `embargo`: each op
|
||||
* needs non-empty `from` and `target` refs and a boolean `lift` (default
|
||||
* false = impose). A missing / malformed top-level object yields
|
||||
* `{ trade: [], embargo: [] }`.
|
||||
*/
|
||||
export function validateEconomyOps(raw: unknown): EconomyOps {
|
||||
const out: EconomyOps = { trade: [], embargo: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(o.trade)) {
|
||||
for (const e of o.trade) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const t = e as Record<string, unknown>;
|
||||
const a = str(t.a, 200);
|
||||
const b = str(t.b, 200);
|
||||
if (!a || !b) continue;
|
||||
const op: EconomyTradeOp = { a, b };
|
||||
if (t.lift === true) op.lift = true;
|
||||
out.trade.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.embargo)) {
|
||||
for (const e of o.embargo) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const t = e as Record<string, unknown>;
|
||||
const from = str(t.from, 200);
|
||||
const target = str(t.target, 200);
|
||||
if (!from || !target) continue;
|
||||
const op: EconomyEmbargoOp = { from, target };
|
||||
if (t.lift === true) op.lift = true;
|
||||
out.embargo.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||
* {b,c} (any two distinct belligerents of the same active war are at war —
|
||||
* see the War type's semantics). Pure; exported for the unit tests.
|
||||
*/
|
||||
export function activeWarPairs(wars: War[]): Set<string> {
|
||||
const pairs = new Set<string>();
|
||||
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||
for (const w of wars) {
|
||||
if (w.status !== 'active') continue;
|
||||
for (let i = 0; i < w.sides.length; i++) {
|
||||
for (let j = i + 1; j < w.sides.length; j++) {
|
||||
pairs.add(key(w.sides[i], w.sides[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
|
||||
export function atWar(wars: War[], a: string, b: string): boolean {
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
return activeWarPairs(wars).has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
|
||||
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
|
||||
* applyTurnResult does) and never touching `prev`. For every unordered pair
|
||||
* of distinct existing nations:
|
||||
*
|
||||
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
|
||||
* of the pair's relation are set to `war` (the engine owns this relation
|
||||
* while the war is active — a manual `relations` entry to a lesser status
|
||||
* on a warring pair is overridden, so the two can never "make peace"
|
||||
* without the war being ended);
|
||||
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
|
||||
* wars that tied them ended this turn) AND the current relation is
|
||||
* `war` → both directions become `hostile` (a war is the strongest
|
||||
* hostility — when it stops, the nations stay hostile, not suddenly
|
||||
* neutral);
|
||||
* - otherwise (not at war now, and the relation is not `war`, or the pair
|
||||
* was never tied by an active war) → untouched: the LLM owns
|
||||
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
|
||||
* no active war ever covered (a preset leftover) stays as-is — an
|
||||
* implicit, unnamed war, never force-created into a War entity and never
|
||||
* clobbered by the sync.
|
||||
*
|
||||
* Never throws.
|
||||
*/
|
||||
export function syncWarRelations(prev: GameState, next: GameState): void {
|
||||
const prevAtWar = activeWarPairs(prev.wars ?? []);
|
||||
const nextAtWar = activeWarPairs(next.wars ?? []);
|
||||
const ids = Object.keys(next.nations);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const a = ids[i];
|
||||
const b = ids[j];
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
const na = next.nations[a];
|
||||
const nb = next.nations[b];
|
||||
if (nextAtWar.has(key)) {
|
||||
na.relations[b] = 'war';
|
||||
nb.relations[a] = 'war';
|
||||
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
|
||||
na.relations[b] = 'hostile';
|
||||
nb.relations[a] = 'hostile';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations -------------
|
||||
//
|
||||
// The engine's DETERMINISTIC effects for the seven hybrid op types. There is no
|
||||
// hidden randomness: the LLM declares an intended `outcome` (success / exposed)
|
||||
// and the engine acts only on that. Each effect is small and documented:
|
||||
//
|
||||
// spy informational only — no stat, no relation (pure intel)
|
||||
// sabotage a small ECONOMY dip on the target
|
||||
// assassinate a small STABILITY dip on the target (absent the named-leaders
|
||||
// feature, #61, a leader's removal sours the polity)
|
||||
// cyber a small MILITARY dip on the target
|
||||
// economic a small ECONOMY dip on the target
|
||||
// information a relation nudge toward hostile (cap hostile) — disinformation
|
||||
// proxy a light relation nudge toward hostile (cap rival) — proxy war
|
||||
//
|
||||
// A successful stat op dips the target's `regionStats` (the single named region
|
||||
// when the target was a region key, otherwise every region the target owns) and
|
||||
// re-derives the target nation's aggregate stats so the effect persists. A
|
||||
// successful information/proxy and any exposed op sour the actor's relation
|
||||
// with the target by one step on the hostile ladder.
|
||||
|
||||
/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */
|
||||
export const HYBRID_STAT_EFFECT: Record<HybridOpType, { stat: StatKey; delta: number } | null> = {
|
||||
spy: null, // informational only
|
||||
sabotage: { stat: 'economy', delta: -3 },
|
||||
assassinate: { stat: 'stability', delta: -5 },
|
||||
cyber: { stat: 'military', delta: -3 },
|
||||
economic: { stat: 'economy', delta: -5 },
|
||||
information: null, // relation nudge only
|
||||
proxy: null, // relation nudge only
|
||||
};
|
||||
|
||||
/**
|
||||
* The hostile-relations ladder, weakest to strongest (the `cap` in
|
||||
* {@link shiftRelationTowardHostile} is a member of this array). A missing
|
||||
* relation is treated as `neutral`; `war` is never touched (a war is its own
|
||||
* entity — a covert op neither fabricates nor ends one).
|
||||
*/
|
||||
const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile'];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b`
|
||||
* ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied
|
||||
* symmetrically to both directions, but never past the given `cap`. A missing
|
||||
* relation starts at `neutral`; a `war` relation is never touched (a covert op
|
||||
* neither fabricates nor downgrades a war). Used for the `information` /
|
||||
* `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the
|
||||
* caller passes a clone — applyTurnResult does). Never throws.
|
||||
*/
|
||||
export function shiftRelationTowardHostile(
|
||||
state: GameState,
|
||||
a: string,
|
||||
b: string,
|
||||
cap: RelationStatus,
|
||||
): void {
|
||||
const na = state.nations[a];
|
||||
const nb = state.nations[b];
|
||||
if (!na || !nb) return;
|
||||
const capIdx = HOSTILE_ORDER.indexOf(cap);
|
||||
if (capIdx === -1) return; // unknown cap: no-op (defensive)
|
||||
const step = (cur: RelationStatus | undefined): RelationStatus | null => {
|
||||
if (cur === 'war') return null; // never touch a war relation
|
||||
const s = cur ?? 'neutral'; // a missing relation is neutral
|
||||
const i = HOSTILE_ORDER.indexOf(s);
|
||||
if (i === -1) return 'neutral'; // defensive: unknown status
|
||||
if (i >= capIdx) return s; // at or past the cap: no change
|
||||
return HOSTILE_ORDER[i + 1];
|
||||
};
|
||||
const stepA = step(na.relations[b]);
|
||||
if (stepA !== null) na.relations[b] = stepA;
|
||||
const stepB = step(nb.relations[a]);
|
||||
if (stepB !== null) nb.relations[a] = stepB;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) The display name of a hybrid op's target for the auto
|
||||
* backfire event: a nation target shows the nation's name; a region target
|
||||
* shows the region's display name (or its raw key when no resolver is given).
|
||||
*/
|
||||
function hybridTargetDisplayName(
|
||||
target: { nation: Nation; regionKey?: string },
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): string {
|
||||
if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey;
|
||||
return target.nation.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region
|
||||
* key) to the concrete nation it affects. A nation ref resolves via
|
||||
* {@link resolveNationRef}; a region key resolves to its SOLE owner (a region
|
||||
* with zero or 2+ owners can't be a valid target). Returns the affected nation
|
||||
* plus the specific region key (when the target was a region) so a stat op can
|
||||
* dip just that region. `validKeys` (when supplied) gates the region-key path;
|
||||
* a ref that is neither a known nation nor a valid region key returns `null`.
|
||||
* Pure; never throws.
|
||||
*/
|
||||
function resolveHybridTarget(
|
||||
state: GameState,
|
||||
target: string,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
): { nation: Nation; regionKey?: string } | null {
|
||||
const id = resolveNationRef(state, target);
|
||||
if (id !== null) {
|
||||
const n = state.nations[id];
|
||||
return n ? { nation: n } : null;
|
||||
}
|
||||
const key = (target ?? '').trim();
|
||||
if (!key) return null;
|
||||
if (validKeys && !validKeys.has(key)) return null; // unknown region key
|
||||
const owners = Object.values(state.nations).filter((n) => n.territory.includes(key));
|
||||
if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region
|
||||
return { nation: owners[0], regionKey: key };
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it
|
||||
* in place and never throwing. Resolution order:
|
||||
* 1. `from` must resolve to a nation (id first, then a unique name); an
|
||||
* unknown / ambiguous actor drops the op.
|
||||
* 2. `target` must resolve to a concrete nation (a nation ref, or a region
|
||||
* key's sole owner); an unresolvable target drops the op.
|
||||
* 3. A self-target (`from` and the target nation are the same) is dropped.
|
||||
* Then, per type:
|
||||
* - an `exposed` op BACKFIRES: no intended effect, the actor's relation with
|
||||
* the target sours by one step (cap hostile), and an auto diplomatic/
|
||||
* failure event is emitted (the engine's own narrative, mirroring the
|
||||
* "repelled" auto-event).
|
||||
* - a successful stat op (sabotage / assassinate / cyber / economic) dips the
|
||||
* target's `regionStats` by its per-type delta — the single named region
|
||||
* when the target was a region key, otherwise every region the target
|
||||
* owns — then re-derives the target nation's aggregate stats (the same
|
||||
* 0-region / no-entry guards as the main recompute), so the effect
|
||||
* persists to the next turn.
|
||||
* - a successful `information` / `proxy` sours the actor's relation with the
|
||||
* target by one step (information caps at hostile, proxy at rival).
|
||||
* - `spy` is informational only (no stat, no relation) — a no-op except for
|
||||
* whatever the LLM narrates in `events`.
|
||||
*/
|
||||
function applyHybridOp(
|
||||
next: GameState,
|
||||
op: HybridOp,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
validKeys?: ReadonlySet<string>,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
actingTurn?: number,
|
||||
): void {
|
||||
const turn = actingTurn ?? next.turn;
|
||||
const from = resolveNationRef(next, op.from);
|
||||
if (from === null) return; // unknown / ambiguous actor: drop
|
||||
const target = resolveHybridTarget(next, op.target, validKeys);
|
||||
if (target === null) return; // unresolvable target: drop
|
||||
if (from === target.nation.id) return; // self-target: drop
|
||||
|
||||
if (op.outcome === 'exposed') {
|
||||
// Backfire: no intended effect; the relation sours by one step (cap
|
||||
// hostile) and an auto diplomatic/failure event is emitted.
|
||||
shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
const actor = next.nations[from];
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: from,
|
||||
type: 'diplomatic',
|
||||
title: 'Covert operation exposed',
|
||||
text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName(
|
||||
target,
|
||||
regionNameOf,
|
||||
)} was exposed; relations sour in response.`,
|
||||
outcome: 'failure',
|
||||
},
|
||||
turn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = HYBRID_STAT_EFFECT[op.type];
|
||||
if (eff) {
|
||||
// A successful stat op: dip the target's region(s), then re-derive.
|
||||
if (target.regionKey !== undefined) {
|
||||
const rs = next.regionStats[target.regionKey];
|
||||
if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
} else {
|
||||
for (const key of target.nation.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (!rs) continue;
|
||||
rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta);
|
||||
}
|
||||
}
|
||||
// Re-derive the target nation's aggregate stats. The main recompute ran
|
||||
// earlier this turn, so without this the dip would be washed out next
|
||||
// turn. The same 0-region / no-entry guards as the main recompute apply.
|
||||
if (target.nation.territory.length > 0) {
|
||||
if (target.nation.territory.some((k) => next.regionStats[k])) {
|
||||
target.nation.stats = deriveNationStats(
|
||||
target.nation.territory,
|
||||
next.regionStats,
|
||||
geoOf,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// information / proxy: a relation nudge (spy is a pure no-op here).
|
||||
if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile');
|
||||
else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival');
|
||||
}
|
||||
|
||||
// --- Economy ops: trade + embargo (v0.35.0, #58) ------------------------------
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) The per-turn economic effect of the economy layer, applied
|
||||
* EVERY committed turn (not just to this turn's declares): each active
|
||||
* bilateral trade grants both parties a small, deterministic economy gain;
|
||||
* each active embargo applies a small, deterministic economy dip to the
|
||||
* target only. Clamped to 0–100. The effect lands on `stats.economy` AND on
|
||||
* every region the nation owns (`regionStats`), mirroring the hybrid-op
|
||||
* effect, and each affected nation's derived stats are re-derived afterwards
|
||||
* (the same 0-region / no-entry guards as `applyHybridOp`). No-op when there
|
||||
* are no active trades or embargoes. Never throws.
|
||||
*/
|
||||
const TRADE_ECONOMY_BONUS = 1;
|
||||
const EMBARGO_ECONOMY_PENALTY = 1;
|
||||
|
||||
export function applyEconomyEffects(
|
||||
next: GameState,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
): void {
|
||||
if (next.trades.length === 0 && next.embargoes.length === 0) return;
|
||||
|
||||
// nation id -> signed economy delta (trades add for both parties, embargoes
|
||||
// subtract for the target; deltas accumulate for nations hit by several).
|
||||
const delta = new Map<string, number>();
|
||||
for (const t of next.trades) {
|
||||
for (const id of [t.a, t.b]) {
|
||||
delta.set(id, (delta.get(id) ?? 0) + TRADE_ECONOMY_BONUS);
|
||||
}
|
||||
}
|
||||
for (const e of next.embargoes) {
|
||||
delta.set(e.target, (delta.get(e.target) ?? 0) - EMBARGO_ECONOMY_PENALTY);
|
||||
}
|
||||
|
||||
const affected = new Set<string>();
|
||||
for (const [id, d] of delta) {
|
||||
const n = next.nations[id];
|
||||
if (!n) continue; // a nation removed this turn (a merge): the delta is dropped
|
||||
const cur = clampStat(n.stats.economy + d);
|
||||
if (cur === n.stats.economy) continue; // no change (already at the clamp)
|
||||
n.stats.economy = cur;
|
||||
for (const key of n.territory) {
|
||||
const rs = next.regionStats[key];
|
||||
if (rs) rs.economy = clampStat(rs.economy + d);
|
||||
}
|
||||
affected.add(id);
|
||||
}
|
||||
|
||||
if (affected.size > 0) {
|
||||
for (const id of affected) {
|
||||
const n = next.nations[id];
|
||||
if (n && n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Apply the turn's economy ops to the active lists on
|
||||
* `next`, RESOLVING the (possibly name-based) refs against the current
|
||||
* state: a no-`lift` trade op declares the bilateral agreement (idempotent —
|
||||
* an already-active pair is not duplicated; the pair is stored sorted, since
|
||||
* a trade is symmetric); `lift: true` ends it (a no-op when absent). A no-
|
||||
* `lift` embargo op imposes the unilateral embargo (idempotent — the ordered
|
||||
* from→target pair is kept as-is); `lift: true` lifts it. An op whose refs
|
||||
* don't resolve to distinct EXISTING nations is dropped (defensive — never
|
||||
* throws). Mutates `next` in place.
|
||||
*/
|
||||
function applyEconomyOps(next: GameState, ops: EconomyOps): void {
|
||||
const tradeKey = (a: string, b: string): string => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||
const embargoKey = (f: string, t: string): string => `${f}\u0000${t}`;
|
||||
|
||||
for (const op of ops.trade) {
|
||||
const a = resolveNationRef(next, op.a);
|
||||
const b = resolveNationRef(next, op.b);
|
||||
if (!a || !b || a === b) continue; // unknown / ambiguous / self: drop
|
||||
const key = tradeKey(a, b);
|
||||
if (op.lift === true) {
|
||||
next.trades = next.trades.filter((t) => tradeKey(t.a, t.b) !== key);
|
||||
} else if (!next.trades.some((t) => tradeKey(t.a, t.b) === key)) {
|
||||
// stored sorted (a < b): a trade is symmetric, so the agreement has a
|
||||
// canonical shape no matter which way the LLM named the pair.
|
||||
next.trades.push(a < b ? { a, b } : { a: b, b: a });
|
||||
}
|
||||
}
|
||||
|
||||
for (const op of ops.embargo) {
|
||||
const from = resolveNationRef(next, op.from);
|
||||
const target = resolveNationRef(next, op.target);
|
||||
if (!from || !target || from === target) continue; // unknown / ambiguous / self: drop
|
||||
const key = embargoKey(from, target);
|
||||
if (op.lift === true) {
|
||||
next.embargoes = next.embargoes.filter((e) => embargoKey(e.from, e.target) !== key);
|
||||
} else if (!next.embargoes.some((e) => embargoKey(e.from, e.target) === key)) {
|
||||
next.embargoes.push({ from, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) Auto-suspend the economy layer when war breaks out: any
|
||||
* active trade or embargo whose two nations are on OPPOSITE sides of an
|
||||
* active war (v0.33, #56) is removed — a war severs the economic link, so
|
||||
* the suspended agreement stops producing its per-turn effect from now on.
|
||||
* A trade/embargo between two non-belligerents, or between two nations on
|
||||
* the SAME side, is untouched. Mutates `next` in place; idempotent. Must be
|
||||
* called AFTER the war declare/end ops of the turn (so it sees the final
|
||||
* `next.wars`). Never throws.
|
||||
*/
|
||||
function suspendEconomyOnWar(next: GameState): void {
|
||||
if (next.trades.length === 0 && next.embargoes.length === 0) return;
|
||||
const warring = activeWarPairs(next.wars);
|
||||
const atWarNow = (x: string, y: string): boolean => {
|
||||
if (x === y) return false;
|
||||
const key = x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`;
|
||||
return warring.has(key);
|
||||
};
|
||||
next.trades = next.trades.filter((t) => !atWarNow(t.a, t.b));
|
||||
next.embargoes = next.embargoes.filter((e) => !atWarNow(e.from, e.target));
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
||||
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
|
||||
* a proposal is a real pending-world change the player will resolve, so it
|
||||
* must survive the retry/nudge path instead of being replaced by the generic
|
||||
* "unclear" result).
|
||||
* "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended
|
||||
* (v0.33.0 #56: both are real world changes).
|
||||
*/
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
@@ -754,6 +1387,16 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
|
||||
result.takeovers.length > 0 ||
|
||||
// (v0.33.0, #56) a war declared or ended is a real world change.
|
||||
result.wars.declare.length > 0 ||
|
||||
result.wars.end.length > 0 ||
|
||||
// (v0.34.0, #57) a hybrid op (a successful one or an exposed backfire) is
|
||||
// a real world change (a stat dip or a soured relation).
|
||||
result.hybrid.ops.length > 0 ||
|
||||
// (v0.35.0, #58) a trade declared/ended or an embargo imposed/lifted this
|
||||
// turn is a real world change (the active economy lists moved).
|
||||
result.economy.trade.length > 0 ||
|
||||
result.economy.embargo.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -1030,6 +1673,112 @@ export function applyTurnResult(
|
||||
n.name = t.newRegimeName;
|
||||
}
|
||||
|
||||
// --- Wars (v0.33.0, #56: war as a first-class state) ---
|
||||
//
|
||||
// Wars are named entities (`GameState.wars`), not just a `war` relation.
|
||||
// Two kinds of change, applied in this order against the (cloned) `next`:
|
||||
//
|
||||
// (1) DECLARE: each validated declare is resolved to concrete nation ids
|
||||
// (`resolveNationRef`, so the LLM may use an id OR a unique name) and
|
||||
// becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
|
||||
// with the acting turn and the turn's new date. Mutually-defensive
|
||||
// allies of the belligerents are DRAGGED IN: any nation that is an
|
||||
// `ally` of a belligerent (per the current, post-relations relations)
|
||||
// and is not already a side joins the war's `sides`. A declare with
|
||||
// fewer than two DISTINCT resolved sides is dropped; a declare whose
|
||||
// sides are all already contained in an existing ACTIVE war is a
|
||||
// duplicate and is skipped (the existing war stands).
|
||||
// (2) END: each validated end targets a war BY ID and sets its status to
|
||||
// the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
|
||||
// affected — an unknown id or an already-ended war is a no-op.
|
||||
// (A war may be declared and ended in the SAME turn: declare runs
|
||||
// first, so the just-declared id is a valid end target.)
|
||||
// (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from
|
||||
// the ACTIVE wars (see its doc) — the load-bearing step that keeps
|
||||
// the relation table honest with the war entities.
|
||||
//
|
||||
// Nothing here throws: every unresolvable / invalid op is dropped.
|
||||
{
|
||||
const takenWarIds = new Set(next.wars.map((w) => w.id));
|
||||
// (1) DECLARE
|
||||
for (const d of result.wars.declare) {
|
||||
const resolved: string[] = [];
|
||||
for (const ref of d.sides) {
|
||||
const id = resolveNationRef(next, ref);
|
||||
if (id !== null && !resolved.includes(id)) resolved.push(id);
|
||||
}
|
||||
if (resolved.length < 2) continue; // not enough distinct belligerents
|
||||
// Duplicate: an active war already contains all of these sides.
|
||||
const dup = next.wars.some(
|
||||
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
|
||||
);
|
||||
if (dup) continue;
|
||||
const sides = [...resolved];
|
||||
// Mutual-defense drag-in: each belligerent's allies join the war.
|
||||
for (const s of resolved) {
|
||||
const rels = next.nations[s]?.relations ?? {};
|
||||
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
|
||||
if (!sides.includes(ally)) sides.push(ally);
|
||||
}
|
||||
}
|
||||
const id = freshWarId(takenWarIds);
|
||||
takenWarIds.add(id);
|
||||
next.wars.push({
|
||||
id,
|
||||
sides,
|
||||
declaredTurn: actingTurn,
|
||||
declaredDate: next.date,
|
||||
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
// (2) END (by id; active only)
|
||||
for (const e of result.wars.end) {
|
||||
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
|
||||
if (!w) continue; // unknown id / already ended: no-op
|
||||
w.status = e.outcome;
|
||||
}
|
||||
// (3) RELATION SYNC (prev = the state this turn started from)
|
||||
syncWarRelations(state, next);
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): covert, non-kinetic operations ---
|
||||
//
|
||||
// Each op's deterministic per-type effect is applied to the post-wars state
|
||||
// (a stat dip on the target's regions, or a relation nudge toward hostile).
|
||||
// An `exposed` op backfires instead. They never move borders, so they run
|
||||
// after the territory changes and before the markers (a marker's owner is
|
||||
// recomputed from the final ownership, unaffected by a stat/relation dip).
|
||||
for (const op of result.hybrid.ops) {
|
||||
applyHybridOp(next, op, geoOf, validKeys, regionNameOf, actingTurn);
|
||||
}
|
||||
|
||||
// --- Trade & embargo economy layer (v0.35.0, #58) ---
|
||||
//
|
||||
// Placed AFTER the wars block (so the auto-suspension sees the FINAL
|
||||
// next.wars, including wars declared this turn) and before markers /
|
||||
// nationOps (which never touch the economy lists). Order:
|
||||
// (1) APPLY OPS: the turn's declare/lift ops mutate the active
|
||||
// GameState.trades / GameState.embargoes lists (a declare is
|
||||
// idempotent; refs resolved against the current state, unknown /
|
||||
// ambiguous / self-pairing ops dropped).
|
||||
// (2) WAR AUTO-SUSPENSION: any active trade or embargo whose two
|
||||
// nations are on opposite sides of an active war is removed —
|
||||
// a war severs the economic link, so the suspended agreement
|
||||
// stops producing its per-turn effect from now on (this also
|
||||
// drops a trade declared this turn between two nations that just
|
||||
// went to war).
|
||||
// (3) PER-TURN EFFECT: the FULL active lists (not just this turn's
|
||||
// declares) each apply their deterministic effect this turn —
|
||||
// trade: both parties' economy up a little; embargo: the
|
||||
// target's economy down a little — clamped, mirrored to the
|
||||
// owning nations' regions, and re-derived like the hybrid dip.
|
||||
// Additive only: declares add, lifts remove, and nothing else (not even
|
||||
// a war) mutates the lists besides the suspension above.
|
||||
applyEconomyOps(next, result.economy);
|
||||
suspendEconomyOnWar(next);
|
||||
applyEconomyEffects(next, geoOf);
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1215,6 +1964,19 @@ export function freshNationId(taken: ReadonlySet<string>): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused war id for a newly declared {@link War}
|
||||
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
|
||||
* it creates to `taken` as it goes, so ids stay unique within a turn and are
|
||||
* never reused from an ended war.
|
||||
*/
|
||||
export function freshWarId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `w${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline stats a freshly created nation starts with (split parts,
|
||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||
@@ -1505,7 +2267,35 @@ export async function runTurn(opts: {
|
||||
regionKey: m.regionKey,
|
||||
owner: m.ownerNationId,
|
||||
}));
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
|
||||
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
|
||||
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
|
||||
// ended wars stay in GameState.wars as history but are not surfaced.
|
||||
const wars = (state.wars ?? [])
|
||||
.filter((w) => w.status === 'active')
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
sides: w.sides,
|
||||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||
}));
|
||||
// (v0.34.0, #57) the player's current relations to every other nation, so the
|
||||
// model sees the ladder its `information` / `proxy` hybrid ops nudge (and the
|
||||
// starting point before an `exposed` backfire). Always emitted ({} when the
|
||||
// player has no relations yet).
|
||||
const relations = state.nations[state.playerNationId]?.relations ?? {};
|
||||
const user = buildUserPrompt(
|
||||
state,
|
||||
items,
|
||||
regionReference,
|
||||
frontier,
|
||||
state.unowned ?? [],
|
||||
markers,
|
||||
wars,
|
||||
relations,
|
||||
// (v0.35.0, #58) the active trade / embargo lists, so the model can
|
||||
// reference — and `lift` — what is currently in force.
|
||||
state.trades,
|
||||
state.embargoes,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
@@ -1558,6 +2348,9 @@ export async function runTurn(opts: {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
hybrid: { ops: [] },
|
||||
economy: { trade: [], embargo: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +178,14 @@ app.post('/api/games', (req: Request, res) => {
|
||||
// real region keys — each region starts as a copy of its owner's initial
|
||||
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) No wars on a fresh game; the LLM declares them over time
|
||||
// (any preset-level `war` relation stays a bare/implicit one, not an
|
||||
// entity — see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No active trades or embargoes on a fresh game; the LLM
|
||||
// declares (and lifts) them over time via TurnResult.economy.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
// 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
|
||||
|
||||
@@ -52,6 +52,11 @@ const state: GameState = {
|
||||
markers: [],
|
||||
// (v0.28, #46) the map is ownership-only; the region-stats table is unused here.
|
||||
regionStats: {},
|
||||
// (v0.33.0, #56) the map is ownership-only; wars are unused here.
|
||||
wars: [],
|
||||
// (v0.35.0, #58) the map is ownership-only; trades/embargoes are unused here.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
|
||||
@@ -41,6 +41,9 @@ function mkState(
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
regionStats: {} as GameState['regionStats'],
|
||||
wars: [],
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats'];
|
||||
else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row
|
||||
|
||||
@@ -110,6 +110,10 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
markers: [],
|
||||
// (v0.28, #46) unused here (allTerritoryKeys is ownership-only).
|
||||
regionStats: {},
|
||||
wars: [],
|
||||
// (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only).
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
|
||||
+46
-1
@@ -3,7 +3,7 @@
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
import type { RelationStatus, RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
@@ -38,6 +38,35 @@ export interface StubPayload {
|
||||
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||
/**
|
||||
* (v0.33.0, #56) Currently ACTIVE wars (`{ id, sides, aims? }`), emitted by
|
||||
* the engine's user prompt. The stub ignores this field — it never declares
|
||||
* or ends a war (see deterministicTurn), so the war path is covered by unit
|
||||
* tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
wars?: Array<{ id: string; sides: string[]; aims?: string[] }>;
|
||||
/**
|
||||
* (v0.34.0, #57) The player's current relations to every other nation
|
||||
* (`nationId -> RelationStatus`), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never runs a hybrid op (see
|
||||
* deterministicTurn), so the hybrid path is covered by unit tests only.
|
||||
*/
|
||||
relations?: Record<string, RelationStatus>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active bilateral trade agreements
|
||||
* (`{ a, b }` nation-id pairs), emitted by the engine's user prompt. The
|
||||
* stub ignores this field — it never declares, lifts or suspends a trade
|
||||
* (see deterministicTurn), so the trade path is covered by unit tests only.
|
||||
*/
|
||||
trades?: Array<{ a: string; b: string }>;
|
||||
/**
|
||||
* (v0.35.0, #58) The currently active unilateral embargoes
|
||||
* (`{ from, target }` nation-id pairs), emitted by the engine's user
|
||||
* prompt. The stub ignores this field — it never imposes, lifts or
|
||||
* suspends an embargo (see deterministicTurn), so the embargo path is
|
||||
* covered by unit tests only.
|
||||
*/
|
||||
embargoes?: Array<{ from: string; target: string }>;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -81,6 +110,19 @@ function truncate(s: string, n: number): string {
|
||||
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||
* unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.33.0, #56) The stub likewise NEVER declares or ends a war (it always
|
||||
* returns `wars: { declare: [], end: [] }`) — the war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.34.0, #57) The stub likewise NEVER runs a hybrid / covert op (it always
|
||||
* returns `hybrid: { ops: [] }`) — the hybrid-war path is covered by unit
|
||||
* tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.35.0, #58) The stub likewise NEVER declares, lifts or suspends a trade
|
||||
* or an embargo (it always returns `economy: { trade: [], embargo: [] }`) —
|
||||
* the trade & embargo economy path is covered by unit tests only, keeping
|
||||
* the e2e assertions untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -148,6 +190,9 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
takeovers: [], // the stub never performs a takeover (see doc comment)
|
||||
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
|
||||
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment)
|
||||
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,50 @@ export interface TerritoryChange {
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
// --- Wars (v0.33.0, #56): first-class war entities --------------------------
|
||||
//
|
||||
// A war is an explicit world entity (see types.War), not just a boolean
|
||||
// relation: the LLM DECLARES one (it becomes an active War with an engine-
|
||||
// assigned stable id) and later ENDS one by id. The engine derives the
|
||||
// symmetric `war` relation from the set of ACTIVE wars on every turn (two
|
||||
// nations are in a `war` relation iff they are both belligerents of at least
|
||||
// one active war) and downgrades it to `hostile` when the war ends — so the
|
||||
// relation can never drift apart from the war state.
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Declare a new war this turn. `sides` names every
|
||||
* belligerent (≥2 distinct nation ids — each resolved by `resolveNationRef`,
|
||||
* so a unique name also works); any two distinct sides end the turn in a
|
||||
* `war` relation. `aims` are optional short war aims for the narrative. The
|
||||
* engine assigns the stable id, the declared turn/date, drags in the
|
||||
* mutual-defense allies of each belligerent, and drops the op when fewer than
|
||||
* two sides resolve (never throws).
|
||||
*/
|
||||
export interface WarDeclareOp {
|
||||
/** The belligerent nations (≥2 distinct ids or unique names). */
|
||||
sides: string[];
|
||||
/** Optional short war aims (narrative only). */
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) End an existing ACTIVE war this turn, by its stable id
|
||||
* (the model sees the current active wars in the user payload's `wars`
|
||||
* field). `outcome` is `armistice` (a negotiated stop) or `capitulation`
|
||||
* (one side surrenders); the war keeps its id/sides and stays in
|
||||
* `GameState.wars` as history with that final status. `terms` are optional
|
||||
* short peace terms (narrative only — the model narrates the details in
|
||||
* `events`). An unknown id, or a war that is already ended, is a no-op
|
||||
* (never throws).
|
||||
*/
|
||||
export interface WarEndOp {
|
||||
/** The stable id of the active war to end (from the user payload). */
|
||||
id: string;
|
||||
outcome: 'armistice' | 'capitulation';
|
||||
/** Optional short peace terms (narrative only). */
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||
// proposes, player confirms" model) ------------------------------------------
|
||||
//
|
||||
@@ -189,6 +233,19 @@ export interface MarkerInfo {
|
||||
owner: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) One ACTIVE war as the user prompt presents it to the model
|
||||
* (the `wars` field): its stable `id` (the model echoes it in
|
||||
* `TurnResult.wars.end` to end it), its belligerent `sides`, and optional
|
||||
* short `aims`. Ended wars are NOT included — they stay in `GameState.wars`
|
||||
* as history, but the model only needs to address the active ones.
|
||||
*/
|
||||
export interface WarInfo {
|
||||
id: string;
|
||||
sides: string[];
|
||||
aims?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-type presentation of a marker: its display label (tooltip /
|
||||
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||
@@ -248,6 +305,117 @@ export function isMarkerType(t: unknown): t is MarkerType {
|
||||
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
// --- Hybrid war (v0.34.0, #57): non-kinetic operations ---------------------
|
||||
//
|
||||
// The LLM can run covert, non-kinetic operations each turn: spies, sabotage,
|
||||
// assassination attempts, cyberattacks, economic pressure, disinformation, and
|
||||
// proxy support. Unlike kinetic conquest (territory), these never move borders
|
||||
// — they dip a target's stats or sour a relation. The LLM declares an intended
|
||||
// outcome (`success` or `exposed`); the engine is fully deterministic (no
|
||||
// hidden randomness) and acts only on that declaration: a successful op applies
|
||||
// its small, per-type effect, and an "exposed" op backfires (no effect, the
|
||||
// actor's relation with the target sours by one step, and an auto
|
||||
// diplomatic/failure event is emitted).
|
||||
|
||||
/** The seven covert operation types a hybrid op can be (v0.34.0, #57). */
|
||||
export type HybridOpType =
|
||||
| 'spy'
|
||||
| 'sabotage'
|
||||
| 'assassinate'
|
||||
| 'cyber'
|
||||
| 'economic'
|
||||
| 'information'
|
||||
| 'proxy';
|
||||
|
||||
/** All hybrid op types, in canonical order (the LLM contract + validator). */
|
||||
export const HYBRID_OP_TYPES: HybridOpType[] = [
|
||||
'spy',
|
||||
'sabotage',
|
||||
'assassinate',
|
||||
'cyber',
|
||||
'economic',
|
||||
'information',
|
||||
'proxy',
|
||||
];
|
||||
|
||||
/**
|
||||
* (v0.34.0, #57) One covert (non-kinetic) operation the LLM may run in a turn
|
||||
* (`TurnResult.hybrid.ops`). `from` is the acting nation (its id or a unique
|
||||
* name — resolved by the engine's `resolveNationRef`). `target` is EITHER a
|
||||
* nation (id or unique name) or a specific region key; a region target hits
|
||||
* that region's sole owner (a region with zero or 2+ owners can't be
|
||||
* targeted). `detail` is a short narrative (the LLM may also narrate the op in
|
||||
* `events`). `outcome` is the LLM's *intended* result: `success` (the default)
|
||||
* applies the op's deterministic effect, while `exposed` backfires it — the
|
||||
* effect is NOT applied, the actor's relation with the target sours by one
|
||||
* step, and an auto diplomatic/failure event is emitted.
|
||||
*/
|
||||
export interface HybridOp {
|
||||
/** The operation type (see {@link HybridOpType}). */
|
||||
type: HybridOpType;
|
||||
/** The acting nation's id or current name (resolved by `resolveNationRef`). */
|
||||
from: string;
|
||||
/** The target: a nation's id/name, or a region key (hits that region's owner). */
|
||||
target: string;
|
||||
/** Short narrative detail (optional). */
|
||||
detail?: string;
|
||||
/** The LLM's intended result; `exposed` backfires the op. Defaults to success. */
|
||||
outcome?: 'success' | 'exposed';
|
||||
}
|
||||
|
||||
// --- Trade & embargo ops (v0.35.0, #58) --------------------------------------
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A bilateral TRADE op in `TurnResult.economy.trade`: `a` and
|
||||
* `b` are the two parties (each a nation's id or unique name — resolved by
|
||||
* the engine's `resolveNationRef`; a pair that resolves to the same nation,
|
||||
* or contains an unknown/ambiguous ref, is dropped defensively). Without
|
||||
* `lift` the op DECLARES the agreement (idempotent — an already-active
|
||||
* agreement is not duplicated); `lift: true` ENDS it.
|
||||
*/
|
||||
export interface EconomyTradeOp {
|
||||
/** First party's id or unique name. */
|
||||
a: string;
|
||||
/** Second party's id or unique name (distinct from `a` after resolution). */
|
||||
b: string;
|
||||
/** `true` → end this bilateral trade; default `false` = declare it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) A unilateral EMBARGO op in `TurnResult.economy.embargo`:
|
||||
* `from` blocks trade with `target` (each a nation's id or unique name —
|
||||
* resolved by `resolveNationRef`; an unknown/ambiguous ref or
|
||||
* `from === target` is dropped defensively). Without `lift` the op imposes
|
||||
* the embargo (idempotent — an already-active one is not duplicated);
|
||||
* `lift: true` LIFTS it.
|
||||
*/
|
||||
export interface EconomyEmbargoOp {
|
||||
/** The imposing nation's id or unique name. */
|
||||
from: string;
|
||||
/** The embargoed nation's id or unique name (distinct from `from`). */
|
||||
target: string;
|
||||
/** `true` → lift this embargo; default `false` = impose it. */
|
||||
lift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) The turn's trade-and-embargo operations, nested under
|
||||
* `TurnResult.economy`. Both arrays are required and default to `[]`. The
|
||||
* engine applies the ops to `GameState.trades` / `GameState.embargoes`
|
||||
* (add = declare/impose, remove = lift) and, on every committed turn, applies
|
||||
* the per-turn economic effect of the FULL active lists (trade: both
|
||||
* parties' economy up a little; embargo: the target's economy down a
|
||||
* little). Active trades/embargoes between nations on opposite sides of an
|
||||
* active war (v0.33, #56) are suspended (removed) automatically.
|
||||
*/
|
||||
export interface EconomyOps {
|
||||
/** Bilateral trades (declare or lift). */
|
||||
trade: EconomyTradeOp[];
|
||||
/** Unilateral embargoes (impose or lift). */
|
||||
embargo: EconomyEmbargoOp[];
|
||||
}
|
||||
|
||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||
export interface TurnResult {
|
||||
date: string;
|
||||
@@ -297,6 +465,47 @@ export interface TurnResult {
|
||||
* (default `[]`).
|
||||
*/
|
||||
takeovers: Takeover[];
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war ops this turn. `declare` starts new wars
|
||||
* (≥2 distinct belligerents; the engine assigns the stable id / declared
|
||||
* turn / date, drags in the mutual-defense allies of each belligerent, and
|
||||
* derives the `war` relation between every pair of sides); `end` closes an
|
||||
* existing ACTIVE war by id (`armistice` = a negotiated stop,
|
||||
* `capitulation` = one side surrenders), which downgrades the relation the
|
||||
* war had derived back to `hostile`. Both sub-fields are required and
|
||||
* default `[]`; a turn with war activity is meaningful (it survives the
|
||||
* retry/nudge path). The model sees the current active wars (id + sides +
|
||||
* aims) in the user payload's `wars` field, so it can `end` by id.
|
||||
*/
|
||||
wars: { declare: WarDeclareOp[]; end: WarEndOp[] };
|
||||
/**
|
||||
* (v0.34.0, #57) Covert, non-kinetic ("hybrid") operations this turn — spies,
|
||||
* sabotage, assassination attempts, cyberattacks, economic pressure,
|
||||
* disinformation, and proxy support. Unlike `nationOps` (player-confirmed
|
||||
* proposals) these are applied directly by the turn, like territory / state
|
||||
* changes: each op's deterministic per-type effect is applied by
|
||||
* `applyTurnResult` (a small stat dip on the target's regions, or a
|
||||
* relation nudge toward hostile for `information` / `proxy`); an `exposed`
|
||||
* op backfires (no effect, the actor's relation with the target sours by
|
||||
* one step, and an auto diplomatic/failure event is emitted). They never
|
||||
* move borders — sole ownership is untouched. Required (default
|
||||
* `{ ops: [] }`).
|
||||
*/
|
||||
hybrid: { ops: HybridOp[] };
|
||||
/**
|
||||
* (v0.35.0, #58) Trade and embargo ops — the economy layer. `trade`
|
||||
* declares (or, with `lift: true`, ends) bilateral trade agreements
|
||||
* between two nations; `embargo` imposes (or, with `lift: true`, lifts)
|
||||
* a unilateral embargo on a target. Both arrays are required and default
|
||||
* to `[]`. Applied directly by `applyTurnResult` (add to / remove from
|
||||
* `GameState.trades` / `GameState.embargoes`), and the per-turn economic
|
||||
* effect of the FULL active lists is applied on every committed turn (a
|
||||
* trade lifts both parties' economy a little; an embargo lowers the
|
||||
* target's economy a little — clamped, deterministic). Active
|
||||
* trades/embargoes between nations on opposite sides of an active war are
|
||||
* suspended automatically.
|
||||
*/
|
||||
economy: EconomyOps;
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
|
||||
@@ -453,6 +453,12 @@ export function worldDraftToGameState(
|
||||
// Seed AFTER the territories hold real region keys: each owned region gets
|
||||
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||
regionStats: seedRegionStats(nations),
|
||||
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
|
||||
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
|
||||
wars: [],
|
||||
// (v0.35.0, #58) No trades/embargoes on a fresh (dynamic) game.
|
||||
trades: [],
|
||||
embargoes: [],
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
+105
@@ -143,6 +143,75 @@ export interface MapMarker {
|
||||
placedTurn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
|
||||
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
|
||||
* op) and ended by a `lift: true` op; while active it yields a small,
|
||||
* deterministic economy gain to BOTH parties every committed turn. The
|
||||
* engine suspends (removes) the trade automatically when the two parties end
|
||||
* up on opposite sides of an active war (v0.33, #56).
|
||||
*/
|
||||
export interface TradeAgreement {
|
||||
/** First party's nation id. */
|
||||
a: string;
|
||||
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
|
||||
b: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
|
||||
* `target` (which must differ from `from`). Declared by the LLM via
|
||||
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
|
||||
* op; while active it applies a small, deterministic economy dip to the
|
||||
* TARGET only, every committed turn. The engine suspends (removes) the
|
||||
* embargo automatically when the two nations end up on opposite sides of an
|
||||
* active war (v0.33, #56).
|
||||
*/
|
||||
export interface Embargo {
|
||||
/** The imposing nation's id. */
|
||||
from: string;
|
||||
/** The embargoed nation's id (distinct from `from`). */
|
||||
target: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
|
||||
* between two or more nations. Unlike the boolean `war` relation (which a
|
||||
* preset or a bare `relations` entry can carry without any structure behind
|
||||
* it), a `War` is an explicit world entity the engine tracks: the LLM
|
||||
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
|
||||
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
|
||||
* `GameState.wars` for the rest of the game as history after it ends.
|
||||
*
|
||||
* **Semantics of `sides`:** the flat list of every belligerent in the war
|
||||
* (≥2 distinct nation ids). Any two distinct members of an active war's
|
||||
* sides are at war with each other — the engine derives the symmetric `war`
|
||||
* relation for every such pair on every turn (see the engine's
|
||||
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
|
||||
* ends. A mutual-defense ally of a belligerent is dragged into the same list
|
||||
* on declaration (the engine does this automatically).
|
||||
*/
|
||||
export interface War {
|
||||
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
|
||||
id: string;
|
||||
/** The belligerent nations (≥2 distinct ids; see the note above). */
|
||||
sides: string[];
|
||||
/** The turn number the war was declared on. */
|
||||
declaredTurn: number;
|
||||
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
|
||||
declaredDate?: string;
|
||||
/** Optional short war aims the LLM names at declaration. */
|
||||
aims?: string[];
|
||||
/**
|
||||
* Lifecycle: `active` until a turn ends the war, then the end outcome —
|
||||
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
|
||||
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
|
||||
*/
|
||||
status: 'active' | 'armistice' | 'capitulation' | 'ended';
|
||||
/** Optional region keys the war's front line runs through (reserved). */
|
||||
front?: string[];
|
||||
}
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
@@ -251,6 +320,42 @@ export interface GameState {
|
||||
* — additive, no data wipe.
|
||||
*/
|
||||
regionStats: Record<string, RegionStats>;
|
||||
/**
|
||||
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
|
||||
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
|
||||
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
|
||||
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
|
||||
* wars on every turn (two nations are in a `war` relation iff they are both
|
||||
* belligerents of ≥1 active war), so the boolean relation and the war
|
||||
* entities can never drift apart. A bare preset `war` relation with no
|
||||
* active war behind it stays as-is (an implicit, unnamed war — never
|
||||
* force-created into an entity). Always `[]` on a fresh game; rows saved
|
||||
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
wars: War[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
|
||||
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
|
||||
* `lift: true` ends). Every committed turn the engine applies a small,
|
||||
* deterministic economy gain to BOTH parties of each active agreement, and
|
||||
* suspends (removes) any agreement whose two parties are on opposite sides
|
||||
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
|
||||
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
|
||||
* additive, no data wipe.
|
||||
*/
|
||||
trades: TradeAgreement[];
|
||||
/**
|
||||
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
|
||||
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
|
||||
* `lift: true` lifts). Every committed turn the engine applies a small,
|
||||
* deterministic economy dip to each embargo's TARGET only, and suspends
|
||||
* (removes) any embargo whose two nations are on opposite sides of an
|
||||
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
|
||||
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
|
||||
* no data wipe.
|
||||
*/
|
||||
embargoes: Embargo[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user