refactor(#74) P3: add the 8 ability modules + ABILITIES registry

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.
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent 5cfbce861b
commit 2d17c743f6
11 changed files with 1831 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
// 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;
},
};