Each per-feature LLM ability is now a self-contained module in server/abilities/ with its own Ability<K> const (schema line, constraint bullet, validator, applier, isMeaningful, stub default, user-payload field). The ordered ABILITIES registry (index.ts) assembles them in the canonical order: takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology. All function bodies are moved VERBATIM from engine.ts. The engine is untouched in this commit — P4 will rewire it to use the registry.
230 lines
11 KiB
TypeScript
230 lines
11 KiB
TypeScript
// 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<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.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));
|
||
}
|
||
|
||
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":"<nationId>","b":"<nationId>","lift":<true|false>}],"embargo":[{"from":"<nationId>","target":"<nationId>","lift":<true|false>}]},',
|
||
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<string, unknown> = {};
|
||
if (ctx.trades !== undefined) out.trades = ctx.trades;
|
||
if (ctx.embargoes !== undefined) out.embargoes = ctx.embargoes;
|
||
return out;
|
||
},
|
||
};
|