feat(#58): trade & embargoes — TurnResult.economy ops (trade/embargo, declare/lift) with a deterministic per-turn effect and war auto-suspension
CI / build + unit + e2e (pull_request) Successful in 2m3s

This commit is contained in:
2026-10-15 17:26:24 +00:00
parent ed91735c8a
commit b5ea8ba5a4
15 changed files with 776 additions and 23 deletions
+30 -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)
@@ -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