// The ECONOMY (trade & embargo) ability (v0.35.0, #58) as a modular // LLM-ability (v0.40.0, #67). A bilateral trade gives BOTH parties a small, // deterministic economy gain every committed turn; a unilateral embargo // lowers only the TARGET's economy every committed turn. Ops are idempotent // (a no-`lift` op declares / imposes; `lift: true` ends / lifts; refs // resolved against the real state at apply time — unknown / ambiguous / // self-pairing ops are dropped, never thrown). Every committed turn the FULL // active lists apply their effect (clamped, mirrored to each affected // nation's regions, re-derived), and any trade/embargo between nations on // opposite sides of an active war is auto-suspended (a war severs the // economic link). All bodies are moved VERBATIM from the pre-refactor // engine.ts. import { clampStat, deriveNationStats } from '../../shared/protocol'; import type { GameState } from '../../shared/types'; import type { EconomyEmbargoOp, EconomyOps, EconomyTradeOp } from '../../shared/protocol'; import { resolveNationRef, str } from './shared'; import { activeWarPairs } from './apply-helpers'; import type { Ability } from './types'; /** * (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.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)); } export const economy: Ability<'economy'> = { field: 'economy', default: () => ({ trade: [], embargo: [] }), validate: (raw) => validateEconomyOps(raw), apply: (state, data, ctx) => { // 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(state, data); suspendEconomyOnWar(state); applyEconomyEffects(state, ctx.geoOf); }, schemaLine: () => ' "economy": {"trade":[{"a":"","b":"","lift":}],"embargo":[{"from":"","target":"","lift":}]},', constraint: () => '- 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.', isMeaningful: (data) => data.trade.length > 0 || data.embargo.length > 0, stateFields: ['trades', 'embargoes'], stub: () => ({ trade: [], embargo: [] }), payload: (ctx) => { const out: Record = {}; if (ctx.trades !== undefined) out.trades = ctx.trades; if (ctx.embargoes !== undefined) out.embargoes = ctx.embargoes; return out; }, };