diff --git a/AGENTS.md b/AGENTS.md index 6aa1f1f..5ce9f19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -378,6 +378,35 @@ history/ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0757662..c445cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 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`. diff --git a/package.json b/package.json index 74f1260..6ef94ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.34.0", + "version": "0.35.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/db.test.ts b/server/db.test.ts index 6be559a..62d2c28 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -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')); diff --git a/server/db.ts b/server/db.ts index 02c6cb0..7d10f5f 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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 diff --git a/server/engine.test.ts b/server/engine.test.ts index 01cc610..7ad1ab4 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -40,6 +40,7 @@ import { runTurn, shiftRelationTowardHostile, syncWarRelations, + validateEconomyOps, validateHybridOps, validateMarkerOps, validateNationOps, @@ -118,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: [], }; } @@ -555,7 +559,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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' }]); @@ -577,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: [] }, hybrid: { ops: [] }, 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' }]); @@ -598,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: [] }, hybrid: { ops: [] }, 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 @@ -617,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: [] }, hybrid: { ops: [] }, 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' }]); }); @@ -1074,7 +1078,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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); @@ -1091,7 +1095,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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([]); @@ -1114,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: [] }, hybrid: { ops: [] }, 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 @@ -1140,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: [] }, hybrid: { ops: [] }, 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'); @@ -1158,7 +1162,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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', () => { @@ -1248,7 +1252,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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', () => { @@ -1363,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: [] }, hybrid: { ops: [] }, 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); }); @@ -1670,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: [] }, hybrid: { ops: [] }, 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: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, 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)', () => { @@ -1685,7 +1689,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1704,6 +1708,7 @@ describe('isMeaningful', () => { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); @@ -1723,6 +1728,7 @@ describe('isMeaningful', () => { takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -1744,6 +1750,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1775,6 +1782,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1797,6 +1805,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1822,6 +1831,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1846,6 +1856,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'de', newRegimeName: ' ' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); @@ -1870,6 +1881,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { ], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1892,6 +1904,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); @@ -1912,6 +1925,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { 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); @@ -1944,6 +1958,7 @@ describe('markers (point placement, v0.27 #44)', () => { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); @@ -1968,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: [] }, hybrid: { ops: [] }, 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: [] }, hybrid: { ops: [] }, 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 @@ -1999,6 +2014,7 @@ describe('markers (point placement, v0.27 #44)', () => { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept @@ -2044,6 +2060,7 @@ describe('markers (point placement, v0.27 #44)', () => { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, @@ -2069,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: [] }, hybrid: { ops: [] }, 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 @@ -2085,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: [] }, hybrid: { ops: [] }, 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: [] }, hybrid: { ops: [] }, 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'); }); @@ -2160,6 +2177,7 @@ describe('markers (point placement, v0.27 #44)', () => { nationOps: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ @@ -2230,6 +2248,9 @@ function mkTurnState( markers: [], regionStats, wars: [], + // (v0.35.0, #58) no active trades or embargoes on a fresh state. + trades: [], + embargoes: [], }; } @@ -2246,6 +2267,7 @@ function mkTurnResult(partial: Partial): TurnResult { takeovers: partial.takeovers ?? [], wars: partial.wars ?? { declare: [], end: [] }, hybrid: partial.hybrid ?? { ops: [] }, + economy: partial.economy ?? { trade: [], embargo: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -2969,6 +2991,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); @@ -3355,6 +3378,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { takeovers: [], wars: { declare: [{ sides: ['a', 'b'] }], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3374,6 +3398,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { takeovers: [], wars: { declare: [], end: [{ id: 'w1', outcome: 'armistice' }] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); @@ -3393,6 +3418,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); @@ -3839,3 +3865,268 @@ describe('stub — hybrid (v0.34.0 #57)', () => { 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: [] }); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index ccbccef..68e8f4f 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -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'; @@ -31,6 +33,9 @@ import { markerId, MAX_MARKERS, nationForPrompt, + type EconomyEmbargoOp, + type EconomyOps, + type EconomyTradeOp, type HybridOp, type HybridOpType, type MarkerInfo, @@ -163,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 @@ -216,6 +223,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' "takeovers": [{"nationRef":"","newRegimeName":""}],', ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', ' "hybrid": {"ops":[{"type":"","from":"","target":"","detail":"","outcome":""}]},', + ' "economy": {"trade":[{"a":"","b":"","lift":}],"embargo":[{"from":"","target":"","lift":}]},', ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', @@ -236,6 +244,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} 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":["",""],"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":"","outcome":""}]} — 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.', @@ -253,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, @@ -295,6 +307,20 @@ export function buildUserPrompt( * Omitted entirely when undefined. */ relations?: Record, + /** + * (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; @@ -409,6 +435,8 @@ export function buildUserPrompt( 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); } @@ -549,6 +577,7 @@ export function validateTurnResult( takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; @@ -653,6 +682,12 @@ export function validateTurnResult( // 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). @@ -881,6 +916,52 @@ function validateHybridOpsList(raw: unknown[]): HybridOp[] { 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; + + if (Array.isArray(o.trade)) { + for (const e of o.trade) { + if (!e || typeof e !== 'object') continue; + const t = e as Record; + 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; + 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}, @@ -1165,6 +1246,128 @@ function applyHybridOp( 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(); + 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(); + 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 @@ -1190,6 +1393,10 @@ export function isMeaningful(result: TurnResult): boolean { // (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 @@ -1546,6 +1753,32 @@ export function applyTurnResult( 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 @@ -2058,6 +2291,10 @@ export async function runTurn(opts: { 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.'; @@ -2113,6 +2350,7 @@ export async function runTurn(opts: { takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, + economy: { trade: [], embargo: [] }, markers: { add: [], remove: [] }, }; } diff --git a/server/index.ts b/server/index.ts index f9d47d6..be39b39 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 diff --git a/server/map.test.ts b/server/map.test.ts index 48cfe41..2ea5a9a 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -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. diff --git a/server/protocol.test.ts b/server/protocol.test.ts index e9ba019..38d00f2 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -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 diff --git a/server/regions.test.ts b/server/regions.test.ts index ef1bb33..28c8ae9 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -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); diff --git a/server/stub.ts b/server/stub.ts index 2799e4c..fd791d6 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -52,6 +52,21 @@ export interface StubPayload { * deterministicTurn), so the hybrid path is covered by unit tests only. */ relations?: Record; + /** + * (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 { @@ -103,6 +118,11 @@ function truncate(s: string, n: number): string { * (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; @@ -172,6 +192,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { 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) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index d7d0c7f..06964e9 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -363,6 +363,59 @@ export interface HybridOp { 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; @@ -439,6 +492,20 @@ export interface TurnResult { * `{ 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 diff --git a/shared/scenario.ts b/shared/scenario.ts index bab7b44..facc4db 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -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; } diff --git a/shared/types.ts b/shared/types.ts index 90daa66..1e41991 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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[]; } /**