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:
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user