Compare commits

...
15 changed files with 1623 additions and 24 deletions
+58 -1
View File
@@ -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)
@@ -350,6 +350,63 @@ history/
(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
+41
View File
@@ -2,6 +2,47 @@
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).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.33.0",
"version": "0.35.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+16
View File
@@ -33,6 +33,9 @@ function makeState(id: string): GameState {
},
// (v0.33.0, #56) no wars.
wars: [],
// (v0.35.0, #58) no active trades or embargoes.
trades: [],
embargoes: [],
};
}
@@ -149,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'));
+4
View File
@@ -53,6 +53,10 @@ export function openDb(filePath: string): Db {
// (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
+716 -20
View File
@@ -6,6 +6,7 @@ import {
markerId,
MAX_MARKERS,
seedRegionStats,
type HybridOp,
type MarkerInfo,
type MarkerSpec,
type NationOp,
@@ -24,6 +25,8 @@ import {
freshNationId,
freshWarId,
GET_NEIGHBOURS_TOOL,
HYBRID_STAT_EFFECT,
isHybridOpType,
isMeaningful,
makeNeighboursExecutor,
NEW_NATION_STATS,
@@ -35,7 +38,10 @@ import {
resolveNationRef,
resolveRegionKey,
runTurn,
shiftRelationTowardHostile,
syncWarRelations,
validateEconomyOps,
validateHybridOps,
validateMarkerOps,
validateNationOps,
validateRegionChanges,
@@ -113,6 +119,9 @@ function baseState(): GameState {
regionStats: seedRegionStats(nations),
// (v0.33.0, #56) no wars on a fresh state.
wars: [],
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
trades: [],
embargoes: [],
};
}
@@ -550,7 +559,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
});
expect(withOps).not.toBeNull();
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
@@ -572,7 +581,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
});
expect(next.nations.de.name).toBe('DE'); // still the old name
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
@@ -593,7 +602,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] }, // new proposal this turn
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, // new proposal this turn
});
expect(next.pendingNationOps).toEqual([
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
@@ -612,7 +621,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
});
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
});
@@ -1069,7 +1078,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
@@ -1086,7 +1095,7 @@ describe('applyTurnResult', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
@@ -1109,7 +1118,7 @@ describe('applyTurnResult', () => {
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
@@ -1135,7 +1144,7 @@ describe('applyTurnResult', () => {
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('wales-bbbb');
@@ -1153,7 +1162,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
@@ -1243,7 +1252,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
@@ -1358,7 +1367,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1665,9 +1674,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1680,7 +1689,7 @@ describe('isMeaningful', () => {
relations: [],
territoryChanges: [],
unowned: [],
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
}),
).toBe(true);
});
@@ -1698,6 +1707,8 @@ describe('isMeaningful', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
}),
).toBe(true);
@@ -1716,6 +1727,8 @@ describe('isMeaningful', () => {
nationOps: [],
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -1736,6 +1749,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1766,6 +1781,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1787,6 +1804,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1811,6 +1830,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1834,6 +1855,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
});
expect(validated!.takeovers).toEqual([]);
@@ -1857,6 +1880,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
{ nationRef: 'de', newRegimeName: 'The Reich' },
],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1878,6 +1903,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1897,6 +1924,8 @@ describe('takeovers (regime change, v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
const next = applyTurnResult(state, result);
@@ -1928,6 +1957,8 @@ describe('markers (point placement, v0.27 #44)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
};
const next = applyTurnResult(state, result, undefined, centroidOf);
@@ -1952,13 +1983,13 @@ describe('markers (point placement, v0.27 #44)', () => {
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add, remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
@@ -1982,6 +2013,8 @@ describe('markers (point placement, v0.27 #44)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: {
add: [
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
@@ -2026,6 +2059,8 @@ describe('markers (point placement, v0.27 #44)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: {
add: [
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
@@ -2051,7 +2086,7 @@ describe('markers (point placement, v0.27 #44)', () => {
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
@@ -2067,14 +2102,14 @@ describe('markers (point placement, v0.27 #44)', () => {
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, markers: { add: [], remove: [] },
unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(reclaimed.markers[0].ownerNationId).toBe('de');
});
@@ -2141,6 +2176,8 @@ describe('markers (point placement, v0.27 #44)', () => {
unowned: [],
nationOps: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] },
});
const { state } = await runTurn({
@@ -2211,6 +2248,9 @@ function mkTurnState(
markers: [],
regionStats,
wars: [],
// (v0.35.0, #58) no active trades or embargoes on a fresh state.
trades: [],
embargoes: [],
};
}
@@ -2226,6 +2266,8 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
nationOps: partial.nationOps ?? [],
takeovers: partial.takeovers ?? [],
wars: partial.wars ?? { declare: [], end: [] },
hybrid: partial.hybrid ?? { ops: [] },
economy: partial.economy ?? { trade: [], embargo: [] },
markers: partial.markers ?? { add: [], remove: [] },
};
}
@@ -2948,6 +2990,8 @@ describe('validateTakeovers (v0.32.0 #54)', () => {
nationOps: [],
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
});
expect(withTakeovers).not.toBeNull();
@@ -3333,6 +3377,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3351,6 +3397,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
}),
).toBe(true);
@@ -3369,6 +3417,8 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
}),
).toBe(false);
@@ -3434,3 +3484,649 @@ describe('stub — wars (v0.33.0 #56)', () => {
expect(result.wars).toEqual({ declare: [], end: [] });
});
});
// --- Hybrid war (v0.34.0, #57) ---------------------------------------------
/**
* Build a two-nation state for the hybrid tests: `a` (the actor) owns region
* `ra1`, `b` (the target) owns region `rb1`, both with uniform 50 stats and the
* given relations. Returns the state plus the two region keys.
*/
function hybState(
rels: { a?: Record<string, RelationStatus>; b?: Record<string, RelationStatus> },
): GameState {
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1']);
a.relations = rels.a ?? {};
b.relations = rels.b ?? {};
return mkTurnState({ a, b }, { ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) }, []);
}
describe('isHybridOpType (v0.34.0 #57)', () => {
it('accepts all seven types and rejects anything else', () => {
for (const t of ['spy', 'sabotage', 'assassinate', 'cyber', 'economic', 'information', 'proxy']) {
expect(isHybridOpType(t)).toBe(true);
}
for (const t of ['bogus', 'SPY', '', null, 5, undefined]) {
expect(isHybridOpType(t)).toBe(false);
}
});
});
describe('validateHybridOps (v0.34.0 #57)', () => {
it('non-object / null / array-of-wrongs input yields []', () => {
expect(validateHybridOps(undefined)).toEqual([]);
expect(validateHybridOps(null)).toEqual([]);
expect(validateHybridOps('x')).toEqual([]);
expect(validateHybridOps(5)).toEqual([]);
expect(validateHybridOps([])).toEqual([]); // a bare [] of "ops" — empty
});
it('accepts BOTH the { ops: [...] } object and a bare array', () => {
const op = { type: 'spy', from: 'a', target: 'b' };
expect(validateHybridOps({ ops: [op] })).toEqual([op]);
expect(validateHybridOps([op])).toEqual([op]);
});
it('keeps a valid op, trims/caps from & target, and drops outcome:success', () => {
const out = validateHybridOps([
{ type: 'sabotage', from: ' a ', target: ' b ', detail: ' blew up a plant ' },
]);
expect(out).toEqual([{ type: 'sabotage', from: 'a', target: 'b', detail: 'blew up a plant' }]);
});
it('stores outcome:exposed but omits the default success', () => {
const out = validateHybridOps([
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
{ type: 'spy', from: 'a', target: 'b', outcome: 'success' },
{ type: 'spy', from: 'a', target: 'b' },
]);
expect(out).toEqual([
{ type: 'spy', from: 'a', target: 'b', outcome: 'exposed' },
{ type: 'spy', from: 'a', target: 'b' },
{ type: 'spy', from: 'a', target: 'b' },
]);
});
it('drops an unknown type, and empty from or target', () => {
const out = validateHybridOps([
{ type: 'bogus', from: 'a', target: 'b' },
{ type: 'spy', from: ' ', target: 'b' },
{ type: 'spy', from: 'a', target: '' },
{ type: 'spy', from: 'a' }, // missing target
]);
expect(out).toEqual([]);
});
it('caps from/target at 200 chars and detail at 300', () => {
const out = validateHybridOps([
{ type: 'spy', from: 'a'.repeat(250), target: 'b'.repeat(250), detail: 'd'.repeat(350) },
]);
expect(out).toHaveLength(1);
expect(out[0]!.from).toHaveLength(200);
expect(out[0]!.target).toHaveLength(200);
expect(out[0]!.detail).toHaveLength(300);
});
});
describe('validateTurnResult — hybrid (v0.34.0 #57)', () => {
it('defaults hybrid to { ops: [] } when the field is absent', () => {
const r = validateTurnResult({ date: 'x' });
expect(r?.hybrid).toEqual({ ops: [] });
});
it('parses a hybrid field into validated ops', () => {
const r = validateTurnResult({
date: 'x',
hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] },
});
expect(r?.hybrid).toEqual({ ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] });
});
});
describe('isMeaningful — hybrid (v0.34.0 #57)', () => {
it('a hybrid op makes an otherwise-empty turn meaningful', () => {
expect(
isMeaningful(mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } })),
).toBe(true);
});
it('an empty hybrid (and nothing else) is NOT meaningful', () => {
expect(isMeaningful(mkTurnResult({ hybrid: { ops: [] } }))).toBe(false);
});
});
describe('shiftRelationTowardHostile (v0.34.0 #57)', () => {
it('steps one rung toward hostile (cap hostile), symmetrically', () => {
const mk = (ab: RelationStatus, ba: RelationStatus) => {
const a = mkNation('a', NS(50, 50, 50, 50), []);
const b = mkNation('b', NS(50, 50, 50, 50), []);
a.relations = { b: ab };
b.relations = { a: ba };
return mkTurnState({ a, b }, {});
};
let s = mk('neutral', 'neutral');
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('rival');
expect(s.nations.b.relations.a).toBe('rival');
s = mk('rival', 'rival');
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('hostile');
s = mk('ally', 'neutral'); // asymmetric: each side steps from its own value
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('neutral'); // ally -> neutral
expect(s.nations.b.relations.a).toBe('rival'); // neutral -> rival
});
it('never moves past the cap (hostile cap, rival cap)', () => {
const mk = (ab: RelationStatus, ba: RelationStatus) => {
const a = mkNation('a', NS(50, 50, 50, 50), []);
const b = mkNation('b', NS(50, 50, 50, 50), []);
a.relations = { b: ab };
b.relations = { a: ba };
return mkTurnState({ a, b }, {});
};
// hostile cap: hostile stays, rival -> hostile
let s = mk('hostile', 'hostile');
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('hostile');
// rival cap: hostile stays (past cap), rival stays (at cap), neutral -> rival
s = mk('hostile', 'hostile');
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
expect(s.nations.a.relations.b).toBe('hostile');
s = mk('rival', 'rival');
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
expect(s.nations.a.relations.b).toBe('rival');
s = mk('neutral', 'neutral');
shiftRelationTowardHostile(s, 'a', 'b', 'rival');
expect(s.nations.a.relations.b).toBe('rival');
});
it('NEVER touches a war relation (neither direction)', () => {
const a = mkNation('a', NS(50, 50, 50, 50), []);
const b = mkNation('b', NS(50, 50, 50, 50), []);
a.relations = { b: 'war' };
b.relations = { a: 'war' };
const s = mkTurnState({ a, b }, {});
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('war');
expect(s.nations.b.relations.a).toBe('war');
});
it('treats a missing relation as neutral and is a no-op for unknown nations', () => {
const a = mkNation('a', NS(50, 50, 50, 50), []);
const b = mkNation('b', NS(50, 50, 50, 50), []);
const s = mkTurnState({ a, b }, {}); // no relations set
shiftRelationTowardHostile(s, 'a', 'b', 'hostile');
expect(s.nations.a.relations.b).toBe('rival'); // neutral -> rival
shiftRelationTowardHostile(s, 'a', 'ghost', 'hostile'); // unknown nation: no-op
expect(s.nations.a.relations.ghost).toBeUndefined();
});
});
describe('applyTurnResult — hybrid (v0.34.0 #57)', () => {
it('sabotage (nation target): dips ALL of the target\'s economy, re-derives, no relation/event', () => {
// b owns TWO regions — a nation-target op must dip both.
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
const state = mkTurnState(
{ a, b },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b' }] } }));
expect(next.regionStats.rb1.economy).toBe(47);
expect(next.regionStats.rb2.economy).toBe(47);
expect(next.nations.b.stats.economy).toBe(47);
expect(next.nations.a.stats.economy).toBe(50); // actor untouched
expect(next.nations.a.relations.b).toBeUndefined(); // no relation nudge for a stat op
expect(next.nations.b.relations.a).toBeUndefined();
expect(next.events).toHaveLength(0); // the engine emits no event for a successful stat op
});
it('a region target dips ONLY that region (not the rest of the nation)', () => {
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1']);
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rb2']);
const state = mkTurnState(
{ a, b },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rb2: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }));
expect(next.regionStats.rb1.military).toBe(47); // the named region
expect(next.regionStats.rb2.military).toBe(50); // the sibling region untouched
});
it('each stat type applies its documented dip', () => {
const run = (type: HybridOp['type']) => {
const state = hybState({});
return applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type, from: 'a', target: 'b' }] } }));
};
expect(run('sabotage').nations.b.stats.economy).toBe(47); // -3
expect(run('assassinate').nations.b.stats.stability).toBe(45); // -5
expect(run('cyber').nations.b.stats.military).toBe(47); // -3
expect(run('economic').nations.b.stats.economy).toBe(45); // -5
});
it('spy is informational only — no stat, no relation change', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'spy', from: 'a', target: 'b' }] } }));
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50));
expect(next.nations.a.relations.b).toBe('neutral');
expect(next.nations.b.relations.a).toBe('neutral');
expect(next.events).toHaveLength(0);
});
it('information nudges the relation one step toward hostile (cap hostile)', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'information', from: 'a', target: 'b' }] } }));
expect(next.nations.a.relations.b).toBe('rival');
expect(next.nations.b.relations.a).toBe('rival');
expect(next.nations.b.stats).toEqual(NS(50, 50, 50, 50)); // no stat change
});
it('proxy nudges the relation but caps at rival (hostile is not reached)', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
expect(next.nations.a.relations.b).toBe('rival');
expect(next.nations.b.relations.a).toBe('rival');
// a hostile pair is left alone by a proxy op (capped at rival)
const state2 = hybState({ a: { b: 'hostile' }, b: { a: 'hostile' } });
const next2 = applyTurnResult(state2, mkTurnResult({ hybrid: { ops: [{ type: 'proxy', from: 'a', target: 'b' }] } }));
expect(next2.nations.a.relations.b).toBe('hostile');
});
it('an EXPOSED op backfires: no stat effect, relation sours one step, auto diplomatic/failure event', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(
state,
mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'b', outcome: 'exposed' }] } }),
);
expect(next.nations.b.stats.economy).toBe(50); // the intended dip did NOT happen
expect(next.regionStats.rb1.economy).toBe(50);
expect(next.nations.a.relations.b).toBe('rival'); // neutral -> rival (cap hostile)
expect(next.nations.b.relations.a).toBe('rival');
expect(next.events).toHaveLength(1);
expect(next.events[0]).toMatchObject({ nation: 'a', type: 'diplomatic', outcome: 'failure', title: "Covert operation exposed" });
});
it('an EXPOSED op never touches a war relation', () => {
const state = hybState({ a: { b: 'war' }, b: { a: 'war' } });
const next = applyTurnResult(
state,
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'b', outcome: 'exposed' }] } }),
);
expect(next.nations.a.relations.b).toBe('war');
expect(next.nations.b.relations.a).toBe('war');
expect(next.events).toHaveLength(1); // the backfire event is still emitted
});
it('drops an unknown actor, a self-target, an unknown region, and a double-owned region', () => {
// unknown actor
let state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
let next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'zzz', target: 'b' }] } }));
expect(next.nations.b.stats.economy).toBe(50);
expect(next.events).toHaveLength(0);
// self-target
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'sabotage', from: 'a', target: 'a' }] } }));
expect(next.nations.a.stats.economy).toBe(50);
expect(next.events).toHaveLength(0);
// unknown region key
state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'not-a-region' }] } }));
expect(next.nations.b.stats.military).toBe(50);
expect(next.events).toHaveLength(0);
// double-owned region (a and b both own 'rx')
const a = mkNation('a', NS(50, 50, 50, 50), ['ra1', 'rx']);
const b = mkNation('b', NS(50, 50, 50, 50), ['rb1', 'rx']);
const st = mkTurnState(
{ a, b },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rx: RS(50, 50, 50, 50) },
);
const nx = applyTurnResult(st, mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rx' }] } }));
expect(nx.regionStats.rx.military).toBe(50); // not a valid sole-owner target: dropped
expect(nx.events).toHaveLength(0);
});
it('a region target outside the valid-key set is dropped (validKeys gate)', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(
state,
mkTurnResult({ hybrid: { ops: [{ type: 'cyber', from: 'a', target: 'rb1' }] } }),
new Set(['ra1']), // rb1 is not a valid key
);
expect(next.regionStats.rb1.military).toBe(50);
expect(next.nations.b.stats.military).toBe(50);
});
it('NEVER moves borders: a hybrid op leaves every nation\'s territory untouched', () => {
const state = hybState({ a: { b: 'neutral' }, b: { a: 'neutral' } });
const next = applyTurnResult(state, mkTurnResult({ hybrid: { ops: [{ type: 'economic', from: 'a', target: 'b' }] } }));
expect(next.nations.a.territory).toEqual(['ra1']);
expect(next.nations.b.territory).toEqual(['rb1']);
expect(next.nations.b.stats.economy).toBe(45); // the op still took effect on stats
});
it('HYBRID_STAT_EFFECT encodes the per-type magnitudes', () => {
expect(HYBRID_STAT_EFFECT.sabotage).toEqual({ stat: 'economy', delta: -3 });
expect(HYBRID_STAT_EFFECT.assassinate).toEqual({ stat: 'stability', delta: -5 });
expect(HYBRID_STAT_EFFECT.cyber).toEqual({ stat: 'military', delta: -3 });
expect(HYBRID_STAT_EFFECT.economic).toEqual({ stat: 'economy', delta: -5 });
expect(HYBRID_STAT_EFFECT.spy).toBeNull();
expect(HYBRID_STAT_EFFECT.information).toBeNull();
expect(HYBRID_STAT_EFFECT.proxy).toBeNull();
});
});
describe('buildSystemPrompt — hybrid contract (v0.34.0 #57)', () => {
it('documents the hybrid field and its constraints in both modes', () => {
for (const withTools of [false, true]) {
const p = buildSystemPrompt(preset, withTools);
expect(p).toContain('"hybrid": {"ops":');
expect(p).toContain('spy|sabotage|assassinate|cyber|economic|information|proxy');
expect(p).toContain('Covert / hybrid ops ("hybrid.ops") are non-kinetic');
expect(p).toContain('NEVER move borders');
expect(p).toContain("sours by one step toward hostile");
expect(p).toContain("a region key hits that region's sole owner");
}
});
});
describe('buildUserPrompt — hybrid relations (v0.34.0 #57)', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
);
it('carries the player\'s relations when provided', () => {
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, { b: 'rival' }));
expect(out.relations).toEqual({ b: 'rival' });
});
it('omits the relations field entirely when the param is undefined', () => {
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined));
expect(out).not.toHaveProperty('relations');
});
});
describe('stub — hybrid (v0.34.0 #57)', () => {
it('the deterministic stub returns empty hybrid ops (it never runs a covert op)', () => {
const payload = {
state: {
turn: 1,
date: '1939-09-01',
playerNationId: 'uk',
nations: {
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
},
},
pending: [{ kind: 'action' as const, text: 'test' }],
};
const result = deterministicTurn(payload);
expect(result.hybrid).toEqual({ ops: [] });
});
});
// ============================================================================
// Economy — trade & embargo (v0.35.0, #58)
//
// The bilateral trade + embargo layer: `GameState.trades` (symmetric pairs,
// stored sorted) and `GameState.embargoes` (ordered from → target). A turn's
// `economy` ops declare/lift them (refs resolved at apply time); EVERY
// committed turn the full active lists apply their deterministic per-turn
// effect (trade: both parties' economy up; embargo: the target's economy
// down), clamped and mirrored to `regionStats`, and any trade/embargo whose
// two nations are on opposite sides of an active war is auto-suspended.
// ============================================================================
describe('validateEconomyOps (v0.35.0 #58)', () => {
it('non-object input yields empty trade/embargo', () => {
expect(validateEconomyOps(undefined)).toEqual({ trade: [], embargo: [] });
expect(validateEconomyOps(null)).toEqual({ trade: [], embargo: [] });
expect(validateEconomyOps('x')).toEqual({ trade: [], embargo: [] });
expect(validateEconomyOps([])).toEqual({ trade: [], embargo: [] });
});
it('keeps well-formed ops (trimmed refs, lift normalized) and drops malformed ones', () => {
const out = validateEconomyOps({
trade: [{ a: ' uk ', b: 'de' }, { a: 'us', b: 'de', lift: true }, 'junk', null, { a: 'us' }],
embargo: [{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }, { from: 'uk' }],
});
expect(out.trade).toEqual([{ a: 'uk', b: 'de' }, { a: 'us', b: 'de', lift: true }]);
expect(out.embargo).toEqual([{ from: 'uk', target: 'de', lift: true }, { from: 'uk', target: 'de' }]);
});
});
describe('validateTurnResult — economy (v0.35.0 #58)', () => {
it('a bare (economy-less) input gets economy defaulted to { trade: [], embargo: [] }', () => {
const out = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
expect(out).not.toBeNull();
expect(out!.economy).toEqual({ trade: [], embargo: [] });
});
it('a well-formed economy payload is kept', () => {
const out = validateTurnResult({
date: '1939-09-03',
events: [],
stateChanges: [],
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b', lift: true }] },
});
expect(out).not.toBeNull();
expect(out!.economy).toEqual({
trade: [{ a: 'a', b: 'b' }],
embargo: [{ from: 'a', target: 'b', lift: true }],
});
});
});
describe('applyTurnResult — economy (v0.35.0 #58)', () => {
it('a declared trade: both parties gain +1 economy (stats + regionStats mirror), and the agreement persists', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
expect(next.nations.a.stats.economy).toBe(61);
expect(next.nations.b.stats.economy).toBe(71);
expect(next.regionStats.ra1.economy).toBe(61);
expect(next.regionStats.rb1.economy).toBe(71);
});
it('the per-turn effect applies to the FULL active lists every turn (a later empty turn re-applies it)', () => {
let state = mkTurnState(
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
);
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }));
expect(state.nations.a.stats.economy).toBe(61);
expect(state.nations.b.stats.economy).toBe(71);
// the next (empty) turn: the still-active trade applies its effect again
state = applyTurnResult(state, mkTurnResult({}));
expect(state.trades).toEqual([{ a: 'a', b: 'b' }]);
expect(state.nations.a.stats.economy).toBe(62);
expect(state.nations.b.stats.economy).toBe(72);
expect(state.regionStats.ra1.economy).toBe(62);
expect(state.regionStats.rb1.economy).toBe(72);
});
it('an embargo lowers only the target economy; the imposer is untouched', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }));
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }]);
expect(next.nations.a.stats.economy).toBe(60); // imposer unchanged
expect(next.nations.b.stats.economy).toBe(69); // the target dips 1
expect(next.regionStats.ra1.economy).toBe(60);
expect(next.regionStats.rb1.economy).toBe(69);
});
it('declares are idempotent: a trade is one symmetric (sorted) agreement; embargoes keep both ordered directions (a duplicate is dropped)', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({
economy: {
trade: [{ a: 'b', b: 'a' }, { a: 'a', b: 'b' }], // the same pair, both orders → one agreement
embargo: [{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }, { from: 'a', target: 'b' }], // both directions kept, the duplicate dropped
},
}));
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
expect(next.embargoes).toEqual([{ from: 'a', target: 'b' }, { from: 'b', target: 'a' }]);
// the net delta is +1 (trade) - 1 (the embargo pointing at each) = 0 for both
expect(next.nations.a.stats.economy).toBe(50);
expect(next.nations.b.stats.economy).toBe(50);
});
it('lift: true ends a trade (the effect stops) and lifts an embargo; lifting an absent one is a no-op', () => {
let state = mkTurnState(
{ a: mkNation('a', NS(50, 60, 50, 50), ['ra1']), b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
);
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [{ from: 'a', target: 'b' }] } }));
// trade +1 for both; the embargo hits b only → a 61, b 70
expect(state.nations.a.stats.economy).toBe(61);
expect(state.nations.b.stats.economy).toBe(70);
// lift both on the next turn (the trade lift is name/ref-insensitive: b→a resolves to the same sorted pair)
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'b', b: 'a', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
expect(state.trades).toEqual([]);
expect(state.embargoes).toEqual([]);
expect(state.nations.a.stats.economy).toBe(61); // no further change
expect(state.nations.b.stats.economy).toBe(70);
// lifting again (absent) is a no-op
state = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b', lift: true }], embargo: [{ from: 'a', target: 'b', lift: true }] } }));
expect(state.trades).toEqual([]);
expect(state.embargoes).toEqual([]);
});
it('name refs resolve (id first, then unique case-insensitive name); the pair is stored sorted by id', () => {
const state = mkTurnState(
{ a: { ...mkNation('a', NS(50, 60, 50, 50), ['ra1']), name: 'Alpha' }, b: mkNation('b', NS(50, 70, 50, 50), ['rb1']) },
{ ra1: RS(50, 60, 50, 50), rb1: RS(50, 70, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ economy: { trade: [{ a: 'alpha', b: 'B' }], embargo: [] } }));
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
});
it('unknown, ambiguous, and self-pairing refs are dropped (never throws)', () => {
const dupA: Nation = { ...mkNation('a1', NS(50, 50, 50, 50), ['ra1']), name: 'DUP' };
const dupB: Nation = { ...mkNation('a2', NS(50, 50, 50, 50), ['rb1']), name: 'DUP' };
const c = mkNation('c', NS(50, 50, 50, 50), ['rc1']);
const state = mkTurnState(
{ a1: dupA, a2: dupB, c },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50), rc1: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({
economy: {
trade: [
{ a: 'zzz', b: 'c' }, // unknown
{ a: 'c', b: 'c' }, // self
{ a: 'DUP', b: 'c' }, // ambiguous name
],
embargo: [{ from: 'zzz', target: 'c' }, { from: 'c', target: 'c' }, { from: 'DUP', target: 'c' }],
},
}));
expect(next.trades).toEqual([]);
expect(next.embargoes).toEqual([]);
});
it('auto-suspension: an existing trade is removed when its two nations go to war this turn', () => {
const state = warState({ a: warN('a'), b: warN('b') }, []);
state.trades = [{ a: 'a', b: 'b' }];
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
expect(next.wars).toHaveLength(1);
expect(next.wars[0].status).toBe('active');
expect(next.trades).toEqual([]); // the war severs the economic link
});
it('a trade declared this turn between two nations that also go to war this turn is dropped', () => {
const state = warState({ a: warN('a'), b: warN('b') }, []);
const next = applyTurnResult(state, mkTurnResult({
wars: { declare: [{ sides: ['a', 'b'] }], end: [] },
economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] },
}));
expect(next.wars).toHaveLength(1);
expect(next.trades).toEqual([]);
});
it('a trade/embargo between a belligerent and a non-belligerent survives the war declaration', () => {
const state = warState({ a: warN('a'), b: warN('b'), c: warN('c') }, []);
state.trades = [{ a: 'a', b: 'c' }];
state.embargoes = [{ from: 'a', target: 'c' }];
const next = applyTurnResult(state, mkTurnResult({ wars: { declare: [{ sides: ['a', 'b'] }], end: [] } }));
expect(next.trades).toEqual([{ a: 'a', b: 'c' }]);
expect(next.embargoes).toEqual([{ from: 'a', target: 'c' }]);
});
it('an ended (armistice) war does not suspend a trade', () => {
const state = warState(
{ a: warN('a'), b: warN('b') },
[{ id: 'w1', sides: ['a', 'b'], declaredTurn: 1, status: 'armistice' }],
);
state.trades = [{ a: 'a', b: 'b' }];
const next = applyTurnResult(state, mkTurnResult({}));
expect(next.trades).toEqual([{ a: 'a', b: 'b' }]);
});
});
describe('isMeaningful — economy (v0.35.0 #58)', () => {
it('a trade-only or embargo-only turn is meaningful; an empty economy is not', () => {
expect(isMeaningful(mkTurnResult({ economy: { trade: [{ a: 'a', b: 'b' }], embargo: [] } }))).toBe(true);
expect(isMeaningful(mkTurnResult({ economy: { trade: [], embargo: [{ from: 'a', target: 'b' }] } }))).toBe(true);
expect(isMeaningful(mkTurnResult({}))).toBe(false);
});
});
describe('buildSystemPrompt — economy contract (v0.35.0 #58)', () => {
it('documents the economy field and its constraints in both modes', () => {
for (const withTools of [false, true]) {
const p = buildSystemPrompt(preset, withTools);
expect(p).toContain('"economy": {"trade":');
expect(p).toContain('Economy ("economy") is the trade & embargo layer');
expect(p).toContain('at most 1-2 operations per turn');
expect(p).toContain('suspended automatically by the engine');
}
});
});
describe('buildUserPrompt — economy (v0.35.0 #58)', () => {
const state = mkTurnState(
{ a: mkNation('a', NS(50, 50, 50, 50), ['ra1']), b: mkNation('b', NS(50, 50, 50, 50), ['rb1']) },
{ ra1: RS(50, 50, 50, 50), rb1: RS(50, 50, 50, 50) },
);
it('carries the active trades and embargoes when provided', () => {
const out = JSON.parse(
buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined, [{ a: 'a', b: 'b' }], [{ from: 'a', target: 'b' }]),
);
expect(out.trades).toEqual([{ a: 'a', b: 'b' }]);
expect(out.embargoes).toEqual([{ from: 'a', target: 'b' }]);
});
it('omits the trades/embargoes fields entirely when undefined', () => {
const out = JSON.parse(buildUserPrompt(state, [], undefined, undefined, [], [], undefined, undefined));
expect(out).not.toHaveProperty('trades');
expect(out).not.toHaveProperty('embargoes');
});
});
describe('stub — economy (v0.35.0 #58)', () => {
it('the deterministic stub returns empty economy ops (it never declares a trade or an embargo)', () => {
const payload = {
state: {
turn: 1,
date: '1939-09-01',
playerNationId: 'uk',
nations: {
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
},
},
pending: [{ kind: 'action' as const, text: 'test' }],
};
const result = deterministicTurn(payload);
expect(result.economy).toEqual({ trade: [], embargo: [] });
});
});
+546 -1
View File
@@ -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,7 @@ import type {
RegionStats,
RoundItem,
StatKey,
TradeAgreement,
War,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
@@ -24,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,
@@ -55,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) ---
@@ -154,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
@@ -206,6 +222,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' ],',
' "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>"]}',
'}',
'',
@@ -225,6 +243,8 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
'- 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.',
@@ -242,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,
@@ -276,6 +299,28 @@ export function buildUserPrompt(
* 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;
@@ -389,6 +434,9 @@ export function buildUserPrompt(
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);
}
@@ -528,6 +576,8 @@ export function validateTurnResult(
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
@@ -626,6 +676,18 @@ export function validateTurnResult(
// 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).
@@ -806,6 +868,100 @@ export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: Wa
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},
@@ -879,6 +1035,339 @@ export function syncWarRelations(prev: GameState, next: GameState): void {
}
}
// --- 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
@@ -901,6 +1390,13 @@ export function isMeaningful(result: TurnResult): boolean {
// (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
@@ -1246,6 +1742,43 @@ export function applyTurnResult(
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
@@ -1744,6 +2277,11 @@ export async function runTurn(opts: {
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,
@@ -1752,6 +2290,11 @@ export async function runTurn(opts: {
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.';
@@ -1806,6 +2349,8 @@ export async function runTurn(opts: {
nationOps: [],
takeovers: [],
wars: { declare: [], end: [] },
hybrid: { ops: [] },
economy: { trade: [], embargo: [] },
markers: { add: [], remove: [] },
};
}
+4
View File
@@ -182,6 +182,10 @@ app.post('/api/games', (req: Request, res) => {
// (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
+3
View File
@@ -54,6 +54,9 @@ const state: GameState = {
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.
+2
View File
@@ -42,6 +42,8 @@ function mkState(
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
+3
View File
@@ -111,6 +111,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
// (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);
+34 -1
View File
@@ -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>>';
@@ -45,6 +45,28 @@ export interface StubPayload {
* 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 {
@@ -92,6 +114,15 @@ function truncate(s: string, n: number): string {
* (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;
@@ -160,6 +191,8 @@ export function deterministicTurn(p: StubPayload): TurnResult {
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)
};
}
+139
View File
@@ -305,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;
@@ -367,6 +478,34 @@ export interface TurnResult {
* 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
+3
View File
@@ -456,6 +456,9 @@ export function worldDraftToGameState(
// (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;
}
+53
View File
@@ -143,6 +143,37 @@ 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
@@ -303,6 +334,28 @@ export interface GameState {
* 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[];
}
/**