diff --git a/server/abilities/apply-helpers.ts b/server/abilities/apply-helpers.ts new file mode 100644 index 0000000..d9bf1a6 --- /dev/null +++ b/server/abilities/apply-helpers.ts @@ -0,0 +1,88 @@ +// Shared, cross-ability `apply` primitives (v0.40.0, #67): the war-derived +// relation machinery that several abilities share. Two abilities (wars, +// nuclear) mutate `GameState.wars` and both need the same "which pairs are at +// war" primitive (`activeWarPairs` / `atWar`); the wars ability applies the +// turn's declare/end ops and then runs the war → relation sync +// (`syncWarRelations`), and the nuclear ability's `endWarsBetween` (which +// lives in ./nuclear.ts) reuses `activeWarPairs` to downgrade the relations a +// just-ended war no longer covers. +// +// The bodies here are moved VERBATIM from the pre-refactor engine.ts (v0.39.0) +// — `engine.ts` re-exports all three so existing callers (server/index.ts, the +// unit tests) keep importing them from the engine unchanged. All functions are +// pure or mutate their `GameState` argument in place and never throw. + +import type { GameState, War } from '../../shared/types'; + +/** + * (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE + * wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c}, + * {b,c} (any two distinct belligerents of the same active war are at war — + * see the War type's semantics). Pure; exported for the unit tests. + */ +export function activeWarPairs(wars: War[]): Set { + const pairs = new Set(); + const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`); + for (const w of wars) { + if (w.status !== 'active') continue; + for (let i = 0; i < w.sides.length; i++) { + for (let j = i + 1; j < w.sides.length; j++) { + pairs.add(key(w.sides[i], w.sides[j])); + } + } + } + return pairs; +} + +/** True when nations `a` and `b` are both belligerents of ≥1 active war. */ +export function atWar(wars: War[], a: string, b: string): boolean { + const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; + return activeWarPairs(wars).has(key); +} + +/** + * (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars, + * MUTATING the (cloned) `next` state in place (the caller passes a clone — + * applyTurnResult does) and never touching `prev`. For every unordered pair + * of distinct existing nations: + * + * - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions + * of the pair's relation are set to `war` (the engine owns this relation + * while the war is active — a manual `relations` entry to a lesser status + * on a warring pair is overridden, so the two can never "make peace" + * without the war being ended); + * - the pair is NOT at war in `next` but WAS at war in `prev` (one of the + * wars that tied them ended this turn) AND the current relation is + * `war` → both directions become `hostile` (a war is the strongest + * hostility — when it stops, the nations stay hostile, not suddenly + * neutral); + * - otherwise (not at war now, and the relation is not `war`, or the pair + * was never tied by an active war) → untouched: the LLM owns + * ally/neutral/rival/hostile freely, and a **bare** `war` relation that + * no active war ever covered (a preset leftover) stays as-is — an + * implicit, unnamed war, never force-created into a War entity and never + * clobbered by the sync. + * + * Never throws. + */ +export function syncWarRelations(prev: GameState, next: GameState): void { + const prevAtWar = activeWarPairs(prev.wars ?? []); + const nextAtWar = activeWarPairs(next.wars ?? []); + const ids = Object.keys(next.nations); + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = ids[i]; + const b = ids[j]; + const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`; + const na = next.nations[a]; + const nb = next.nations[b]; + if (nextAtWar.has(key)) { + na.relations[b] = 'war'; + nb.relations[a] = 'war'; + } else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) { + na.relations[b] = 'hostile'; + nb.relations[a] = 'hostile'; + } + } + } +} diff --git a/server/abilities/economy.ts b/server/abilities/economy.ts new file mode 100644 index 0000000..c5924ed --- /dev/null +++ b/server/abilities/economy.ts @@ -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; + + 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; + }, +}; diff --git a/server/abilities/hybrid.ts b/server/abilities/hybrid.ts new file mode 100644 index 0000000..36c8c4d --- /dev/null +++ b/server/abilities/hybrid.ts @@ -0,0 +1,291 @@ +// The HYBRID-WAR ability (v0.34.0, #57) as a modular LLM-ability (v0.40.0, #67). +// Covert, non-kinetic operations: the seven op types each have a DETERMINISTIC +// engine effect (no hidden randomness — the LLM declares the intended `outcome` +// and the engine acts only on that): +// +// spy informational only — no stat, no relation (pure intel) +// sabotage a small ECONOMY dip on the target +// assassinate a small STABILITY dip on the target (absent the named-leaders +// feature, #61, a leader's removal sours the polity) +// cyber a small MILITARY dip on the target +// economic a small ECONOMY dip on the target +// information a relation nudge toward hostile (cap hostile) — disinformation +// proxy a light relation nudge toward hostile (cap rival) — proxy war +// +// A successful stat op dips the target's `regionStats` (the single named region +// when the target was a region key, otherwise every region the target owns) and +// re-derives the target nation's aggregate stats so the effect persists. A +// successful information/proxy and any exposed op sour the actor's relation +// with the target by one step on the hostile ladder. All bodies are moved +// VERBATIM from the pre-refactor engine.ts. + +import { clampStat, deriveNationStats, HYBRID_OP_TYPES, makeEvent } from '../../shared/protocol'; +import type { GameState, Nation, RelationStatus, StatKey } from '../../shared/types'; +import type { HybridOp, HybridOpType } from '../../shared/protocol'; +import { resolveNationRef, str } from './shared'; +import type { Ability } from './types'; + +const VALID_HYBRID_TYPES = new Set(HYBRID_OP_TYPES); + +/** True when `t` is one of the seven known hybrid op types (v0.34.0, #57). */ +export function isHybridOpType(t: unknown): t is HybridOpType { + return typeof t === 'string' && VALID_HYBRID_TYPES.has(t); +} + +/** + * (v0.34.0, #57) Validate the raw `hybrid` field of a turn into a safe + * `{ ops }` shape. Defensive like the rest of the parser — never throws. The + * raw value may be the object `{ ops: [...] }` (the TurnResult shape) or a bare + * array (be lenient about either). Each op needs a known `type` (one of + * {@link HYBRID_OP_TYPES}), a non-empty `from` (the acting nation, capped at + * 200 chars) and a non-empty `target` (a nation id/name or a region key, capped + * at 200 chars); an `outcome` of exactly `success` or `exposed` is kept (the + * default, `success`, is stored as absent to keep the shape minimal — the + * engine treats a missing outcome as success); a non-empty `detail` is capped + * at 300 chars. Resolution of `from` / `target` to concrete nation ids or + * region owners happens at apply time (`applyHybridOp`), not here. + */ +export function validateHybridOps(raw: unknown): HybridOp[] { + if (!Array.isArray(raw)) { + // Lenient: accept the `{ ops: [...] }` object shape (or a non-array → []). + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const inner = (raw as Record).ops; + if (!Array.isArray(inner)) return []; + return validateHybridOpsList(inner); + } + return []; + } + return validateHybridOpsList(raw); +} + +function validateHybridOpsList(raw: unknown[]): HybridOp[] { + const out: HybridOp[] = []; + for (const e of raw) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + if (!isHybridOpType(o.type)) continue; + const from = str(o.from, 200); + const target = str(o.target, 200); + if (!from || !target) continue; + const detail = str(o.detail, 300); + const exposed = o.outcome === 'exposed'; // 'success' / missing → success + out.push({ + type: o.type, + from, + target, + ...(detail ? { detail } : {}), + ...(exposed ? { outcome: 'exposed' as const } : {}), + }); + } + return out; +} + +/** The deterministic per-type stat effect of a hybrid op (v0.34.0, #57). */ +export const HYBRID_STAT_EFFECT: Record = { + spy: null, // informational only + sabotage: { stat: 'economy', delta: -3 }, + assassinate: { stat: 'stability', delta: -5 }, + cyber: { stat: 'military', delta: -3 }, + economic: { stat: 'economy', delta: -5 }, + information: null, // relation nudge only + proxy: null, // relation nudge only +}; + +/** + * The hostile-relations ladder, weakest to strongest (the `cap` in + * {@link shiftRelationTowardHostile} is a member of this array). A missing + * relation is treated as `neutral`; `war` is never touched (a war is its own + * entity — a covert op neither fabricates nor ends one). + */ +const HOSTILE_ORDER: RelationStatus[] = ['ally', 'neutral', 'rival', 'hostile']; + +/** + * (v0.34.0, #57) Nudge the bilateral relation between nations `a` and `b` + * ONE step toward hostile (on the {@link HOSTILE_ORDER} ladder), applied + * symmetrically to both directions, but never past the given `cap`. A missing + * relation starts at `neutral`; a `war` relation is never touched (a covert op + * neither fabricates nor downgrades a war). Used for the `information` / + * `proxy` nudge and the `exposed` backfire. Mutates `state` in place (the + * caller passes a clone — applyTurnResult does). Never throws. + */ +export function shiftRelationTowardHostile( + state: GameState, + a: string, + b: string, + cap: RelationStatus, +): void { + const na = state.nations[a]; + const nb = state.nations[b]; + if (!na || !nb) return; + const capIdx = HOSTILE_ORDER.indexOf(cap); + if (capIdx === -1) return; // unknown cap: no-op (defensive) + const step = (cur: RelationStatus | undefined): RelationStatus | null => { + if (cur === 'war') return null; // never touch a war relation + const s = cur ?? 'neutral'; // a missing relation is neutral + const i = HOSTILE_ORDER.indexOf(s); + if (i === -1) return 'neutral'; // defensive: unknown status + if (i >= capIdx) return s; // at or past the cap: no change + return HOSTILE_ORDER[i + 1]; + }; + const stepA = step(na.relations[b]); + if (stepA !== null) na.relations[b] = stepA; + const stepB = step(nb.relations[a]); + if (stepB !== null) nb.relations[a] = stepB; +} + +/** + * (v0.34.0, #57) The display name of a hybrid op's target for the auto + * backfire event: a nation target shows the nation's name; a region target + * shows the region's display name (or its raw key when no resolver is given). + */ +function hybridTargetDisplayName( + target: { nation: Nation; regionKey?: string }, + regionNameOf?: (key: string) => string | undefined, +): string { + if (target.regionKey !== undefined) return regionNameOf?.(target.regionKey) ?? target.regionKey; + return target.nation.name; +} + +/** + * (v0.34.0, #57) Resolve a hybrid op's `target` (a nation id/name OR a region + * key) to the concrete nation it affects. A nation ref resolves via + * {@link resolveNationRef}; a region key resolves to its SOLE owner (a region + * with zero or 2+ owners can't be a valid target). Returns the affected nation + * plus the specific region key (when the target was a region) so a stat op can + * dip just that region. `validKeys` (when supplied) gates the region-key path; + * a ref that is neither a known nation nor a valid region key returns `null`. + * Pure; never throws. + */ +function resolveHybridTarget( + state: GameState, + target: string, + validKeys?: ReadonlySet, +): { nation: Nation; regionKey?: string } | null { + const id = resolveNationRef(state, target); + if (id !== null) { + const n = state.nations[id]; + return n ? { nation: n } : null; + } + const key = (target ?? '').trim(); + if (!key) return null; + if (validKeys && !validKeys.has(key)) return null; // unknown region key + const owners = Object.values(state.nations).filter((n) => n.territory.includes(key)); + if (owners.length !== 1) return null; // 0 or 2+ owners: not a targetable region + return { nation: owners[0], regionKey: key }; +} + +/** + * (v0.34.0, #57) Apply ONE hybrid op to the (cloned) `next` state, mutating it + * in place and never throwing. Resolution order: + * 1. `from` must resolve to a nation (id first, then a unique name); an + * unknown / ambiguous actor drops the op. + * 2. `target` must resolve to a concrete nation (a nation ref, or a region + * key's sole owner); an unresolvable target drops the op. + * 3. A self-target (`from` and the target nation are the same) is dropped. + * Then, per type: + * - an `exposed` op BACKFIRES: no intended effect, the actor's relation with + * the target sours by one step (cap hostile), and an auto diplomatic/ + * failure event is emitted (the engine's own narrative, mirroring the + * "repelled" auto-event). + * - a successful stat op (sabotage / assassinate / cyber / economic) dips the + * target's `regionStats` by its per-type delta — the single named region + * when the target was a region key, otherwise every region the target + * owns — then re-derives the target nation's aggregate stats (the same + * 0-region / no-entry guards as the main recompute), so the effect + * persists to the next turn. + * - a successful `information` / `proxy` sours the actor's relation with the + * target by one step (information caps at hostile, proxy at rival). + * - `spy` is informational only (no stat, no relation) — a no-op except for + * whatever the LLM narrates in `events`. + */ +export function applyHybridOp( + next: GameState, + op: HybridOp, + geoOf?: (key: string) => { area: number; population: number } | undefined, + validKeys?: ReadonlySet, + regionNameOf?: (key: string) => string | undefined, + actingTurn?: number, +): void { + const turn = actingTurn ?? next.turn; + const from = resolveNationRef(next, op.from); + if (from === null) return; // unknown / ambiguous actor: drop + const target = resolveHybridTarget(next, op.target, validKeys); + if (target === null) return; // unresolvable target: drop + if (from === target.nation.id) return; // self-target: drop + + if (op.outcome === 'exposed') { + // Backfire: no intended effect; the relation sours by one step (cap + // hostile) and an auto diplomatic/failure event is emitted. + shiftRelationTowardHostile(next, from, target.nation.id, 'hostile'); + const actor = next.nations[from]; + next.events.push( + makeEvent( + { + nation: from, + type: 'diplomatic', + title: 'Covert operation exposed', + text: `${actor?.name ?? from}'s ${op.type} operation against ${hybridTargetDisplayName( + target, + regionNameOf, + )} was exposed; relations sour in response.`, + outcome: 'failure', + }, + turn, + next.date, + ), + ); + return; + } + + const eff = HYBRID_STAT_EFFECT[op.type]; + if (eff) { + // A successful stat op: dip the target's region(s), then re-derive. + if (target.regionKey !== undefined) { + const rs = next.regionStats[target.regionKey]; + if (rs) rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta); + } else { + for (const key of target.nation.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs[eff.stat] = clampStat(rs[eff.stat] + eff.delta); + } + } + // Re-derive the target nation's aggregate stats. The main recompute ran + // earlier this turn, so without this the dip would be washed out next + // turn. The same 0-region / no-entry guards as the main recompute apply. + if (target.nation.territory.length > 0) { + if (target.nation.territory.some((k) => next.regionStats[k])) { + target.nation.stats = deriveNationStats( + target.nation.territory, + next.regionStats, + geoOf, + ); + } + } + return; + } + + // information / proxy: a relation nudge (spy is a pure no-op here). + if (op.type === 'information') shiftRelationTowardHostile(next, from, target.nation.id, 'hostile'); + else if (op.type === 'proxy') shiftRelationTowardHostile(next, from, target.nation.id, 'rival'); +} + +export const hybrid: Ability<'hybrid'> = { + field: 'hybrid', + default: () => ({ ops: [] }), + // The low-level validator returns a bare list; the field's shape is the + // `{ ops }` object (mirrors the historical `result.hybrid = { ops: ... }`). + validate: (raw) => ({ ops: validateHybridOps(raw) }), + apply: (state, data, ctx) => { + for (const op of data.ops) { + applyHybridOp(state, op, ctx.geoOf, ctx.validRegionKeys, ctx.regionNameOf, ctx.actingTurn); + } + }, + schemaLine: () => + ' "hybrid": {"ops":[{"type":"","from":"","target":"","detail":"","outcome":""}]},', + constraint: () => + '- Covert / hybrid ops ("hybrid.ops") are non-kinetic operations — spies, sabotage, assassination attempts, cyberattacks, economic pressure, disinformation, and proxy support. Unlike territory they NEVER move borders: a successful op applies a small, deterministic effect (a small dip in the target\'s economy / stability / military for sabotage / assassinate / cyber / economic, or a relation nudge toward hostile for information / proxy), while an "exposed" op backfires (no effect, and the actor\'s relation with the target sours by one step toward hostile). "target" may be a nation id / name or a region key (a region key hits that region\'s sole owner). Keep each detail short, propose at most a few per turn, and narrate each op in "events".', + isMeaningful: (data) => data.ops.length > 0, + stub: () => ({ ops: [] }), + payload: (ctx) => (ctx.relations !== undefined ? { relations: ctx.relations } : {}), +}; diff --git a/server/abilities/ideology.ts b/server/abilities/ideology.ts new file mode 100644 index 0000000..33f770d --- /dev/null +++ b/server/abilities/ideology.ts @@ -0,0 +1,123 @@ +// The IDEOLOGY ability (v0.39, #62) as a modular LLM-ability (v0.40.0, #67). +// The model may change a nation's form of government (its `ideology` field) — +// a free-form label such as "democracy", "fascism", or "military junta" — via +// a coup, revolution, reform, or constitutional change. On a valid change the +// target's `ideology` is set, a DETERMINISTIC stability dip +// (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability` +// (clamped) and mirrored to every region the nation owns (`regionStats`), the +// nation's derived stats are re-derived afterwards (the same 0-region / +// no-entry guards), and a narration event is appended. The change never moves +// territory (sole ownership is untouched). All bodies are moved VERBATIM from +// the pre-refactor engine.ts. + +import { deriveNationStats, makeEvent } from '../../shared/protocol'; +import { clampStat, IDEOLOGY_STABILITY_EFFECT } from '../../shared/protocol'; +import type { GameState } from '../../shared/types'; +import type { IdeologyChange } from '../../shared/protocol'; +import { resolveNationRef, str } from './shared'; +import type { Ability } from './types'; + +/** + * (v0.39, #62) Validate the raw `ideology` object of a turn into a safe + * `IdeologyChange[]`. Defensive like the rest of the parser — never throws. + * Accepts a `{ changes: [...] }` object or a bare array (lenient). Each + * change needs a non-empty `target` (a nation ref, capped at 120 chars) and + * a non-empty, non-whitespace `to` (the new form of government, capped at + * 120 chars). Refs stay raw here (resolved against the real state at apply + * time, like the wars / hybrid / nuclear / leader ops); an unresolvable + * target is dropped at apply time, never thrown. + */ +export function validateIdeologyOps(raw: unknown): IdeologyChange[] { + const out: IdeologyChange[] = []; + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? (raw as Record).changes + : undefined; + if (!Array.isArray(list)) return out; + for (const e of list) { + if (!e || typeof e !== 'object') continue; + const s = e as Record; + const target = str(s.target, 120); + const to = str(s.to, 120); + if (!target || !to) continue; + out.push({ target, to }); + } + return out; +} + +/** + * (v0.39, #62) Apply a single ideology change to `next` (in place). The + * target nation is resolved by the shared resolver (id first, then a + * case-insensitive unique name; an unknown or ambiguous ref is dropped, + * never thrown). On a valid change the target's `ideology` is set to the new + * government type (re-setting the same value is idempotent for the field but + * still narrated) and a DETERMINISTIC stability dip + * (`IDEOLOGY_STABILITY_EFFECT`, −5) is applied to its `stats.stability` + * (clamped 0–100) and mirrored to every region the nation owns + * (`regionStats`); the nation's derived stats are re-derived afterwards (the + * same 0-region / no-entry guards as the hybrid/economy/industry/leader + * effects, so a 0-region nation keeps its directly-mutated value). Finally a + * narration event is appended: a `political` event for the government shift. + * The change never moves territory (sole ownership is untouched). + */ +function applyIdeologyChange( + next: GameState, + op: IdeologyChange, + geoOf?: (key: string) => { area: number; population: number } | undefined, + actingTurn?: number, +): void { + const turn = actingTurn ?? next.turn; + const id = resolveNationRef(next, op.target); + if (id === null) return; // unknown / ambiguous target: drop, never throw + const n = next.nations[id]; + + n.ideology = op.to; + + const delta = IDEOLOGY_STABILITY_EFFECT; + if (delta !== 0) { + n.stats.stability = clampStat(n.stats.stability + delta); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs.stability = clampStat(rs.stability + delta); + } + // Re-derive the nation's aggregate stats (the main recompute ran earlier + // this turn, so without this the dip would be washed out next turn). + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } + } + + next.events.push( + makeEvent( + { + nation: n.id, + type: 'political', + title: `${n.name}'s government shifted to ${op.to}`, + text: `${n.name} has changed its form of government to ${op.to}.`, + outcome: 'success', + }, + turn, + next.date, + ), + ); +} + +export const ideology: Ability<'ideology'> = { + field: 'ideology', + default: () => ({ changes: [] }), + // The low-level validator returns a bare list; the field's shape is the + // `{ changes }` object (mirrors the historical `result.ideology = { changes: ... }`). + validate: (raw) => ({ changes: validateIdeologyOps(raw) }), + apply: (state, data, ctx) => { + for (const ch of data.changes) applyIdeologyChange(state, ch, ctx.geoOf, ctx.actingTurn); + }, + schemaLine: () => + ' "ideology": {"changes":[{"target":"","to":""}]},', + constraint: () => + '- Ideology ("ideology") is each nation\'s form of government — a free-form label such as "democracy", "fascism", or "military junta" — that can shift through a coup, a revolution, a reform, or a constitutional change. Each change is {"target":"","to":""}: the engine sets the nation\'s ideology, applies a small, deterministic stability dip (−5 — a regime shift always unsettles a nation), and narrates it with a "political" event. It is a third axis, distinct from a takeover (which RENAMES the nation) and from a leader change (who is in charge): the nation keeps its id, name, territory, relations, and leader — only its form of government and its stability move. Similar forms of government are a SOFT, prompt-level compatibility nudge only (nations with alike ideologies tend to ally and trade; very opposed ones tend to clash) — there is no engine gate behind it. Propose at most 1-2 changes per turn and narrate each one in "events". The user message\'s "nations" field lists each nation\'s current "ideology" (omitted when a nation has none).', + isMeaningful: (data) => data.changes.length > 0, + stub: () => ({ changes: [] }), + payload: () => ({}), +}; diff --git a/server/abilities/index.ts b/server/abilities/index.ts new file mode 100644 index 0000000..8a61987 --- /dev/null +++ b/server/abilities/index.ts @@ -0,0 +1,127 @@ +// The ordered LLM-ability registry. +// +// `ABILITIES` is the single source of truth for the 8 per-feature LLM abilities. +// The engine iterates it in this order: +// takeovers → wars → hybrid → economy → industry → nuclear → leaders → ideology +// for (a) the system-prompt schema lines + constraint bullets, (b) the per-ability +// validate calls inside validateTurnResult, (c) the payload fields in buildUserPrompt, +// (d) the ordered apply block inside applyTurnResult, (e) the isMeaningful check, +// (f) the runTurn unclear-fallback defaults, (g) the stub's deterministic fields, and +// (h) the db.ts state-field migrations. +// +// Each ability file defines its own `Ability` const (the schema line, constraint +// bullet, validator, applier, meaningfulness predicate, stub default, and user-payload +// field). This module assembles them into the ordered `ABILITIES` array and re-exports +// every public name the engine (and its tests) need. + +import { takeovers } from './takeovers'; +import { wars } from './wars'; +import { hybrid } from './hybrid'; +import { economy } from './economy'; +import { industry } from './industry'; +import { nuclear } from './nuclear'; +import { leaders } from './leaders'; +import { ideology } from './ideology'; +import type { Ability, AbilityField } from './types'; + +/** + * The 8 per-feature LLM abilities in their canonical apply order. + * The order matters: it determines the order of the schema lines in the + * system prompt, the order of the validate calls, the order of the apply + * steps, and the order of the payload fields in the user prompt. + */ +export const ABILITIES: readonly Ability[] = [ + takeovers, + wars, + hybrid, + economy, + industry, + nuclear, + leaders, + ideology, +]; + +// ── type re-exports ──────────────────────────────────────────────────────────── + +export type { + Ability, + AbilityCtx, + UserPromptCtx, + AbilityField, +} from './types'; + +// ── shared helpers ───────────────────────────────────────────────────────────── + +export { + resolveNationRef, + resolveRegionKey, + freshNationId, + freshWarId, + NEW_NATION_STATS, +} from './shared'; + +// ── war helpers ──────────────────────────────────────────────────────────────── + +export { + activeWarPairs, + atWar, + syncWarRelations, +} from './apply-helpers'; + +// ── takeovers ────────────────────────────────────────────────────────────────── + +export { + validateTakeovers, + applyTakeover, +} from './takeovers'; + +// ── wars ─────────────────────────────────────────────────────────────────────── + +export { + validateWarOps, + applyWars, +} from './wars'; + +// ── hybrid ───────────────────────────────────────────────────────────────────── + +export { + validateHybridOps, + isHybridOpType, + HYBRID_STAT_EFFECT, + shiftRelationTowardHostile, + applyHybridOp, +} from './hybrid'; + +// ── economy ──────────────────────────────────────────────────────────────────── + +export { + validateEconomyOps, + applyEconomyEffects, +} from './economy'; + +// ── industry ─────────────────────────────────────────────────────────────────── + +export { + validateIndustryOps, + isIndustryType, + applyIndustryEffects, +} from './industry'; + +// ── nuclear ──────────────────────────────────────────────────────────────────── + +export { + validateNuclearOps, + applyNuclearOps, +} from './nuclear'; + +// ── leaders ──────────────────────────────────────────────────────────────────── + +export { + validateLeaderOps, +} from './leaders'; + +// ── ideology ─────────────────────────────────────────────────────────────────── + +export { + validateIdeologyOps, +} from './ideology'; diff --git a/server/abilities/industry.ts b/server/abilities/industry.ts new file mode 100644 index 0000000..6146d80 --- /dev/null +++ b/server/abilities/industry.ts @@ -0,0 +1,212 @@ +// The INDUSTRY ability (v0.36, #59) as a modular LLM-ability (v0.40.0, #67). +// The per-region economy-boost lever: each project is a building in one +// region (`{ regionKey, type }`, type one of oil / iron / food / industry). +// `GameState.industry` is a PER-REGION SET — a region key that is not a valid +// manifest key is dropped; a region that already hosts a project is untouched +// (the first declared type wins — re-declaring is a no-op). Every committed +// turn each active project grants its region's SOLE owner a small, +// deterministic economy boost (`INDUSTRY_ECONOMY_BONUS`, plus one extra point +// when the region's `industry` resource value is ≥ 0.5 — from the synthesized +// resource manifest; without it just the base) — clamped to 0–100, mirrored +// to every region the owner holds, and re-derived like the economy/hybrid +// effects. A project on a region with 0 or 2+ owners yields no boost but is +// kept. Sole ownership is untouched (the layer moves stats, never territory). +// All bodies are moved VERBATIM from the pre-refactor engine.ts. + +import { clampStat, deriveNationStats } from '../../shared/protocol'; +import type { GameState, IndustryType, RegionResources } from '../../shared/types'; +import { INDUSTRY_TYPES } from '../../shared/types'; +import type { IndustryOps } from '../../shared/protocol'; +import { str } from './shared'; +import type { Ability } from './types'; + +const VALID_INDUSTRY_TYPES = new Set(INDUSTRY_TYPES); + +/** True when `t` is one of the four known industry project types (v0.36, #59). */ +export function isIndustryType(t: unknown): t is IndustryType { + return typeof t === 'string' && VALID_INDUSTRY_TYPES.has(t); +} + +/** + * (v0.36, #59) Validate the raw `industry` object of a turn into a safe + * {@link IndustryOps}. Defensive like the rest of the parser — never throws. + * `projects`: each op needs a non-empty `regionKey` (capped at 120 chars) and a + * known project {@link IndustryType} (one of INDUSTRY_TYPES); ops with a + * missing/malformed field are dropped. The region key is kept raw here (like + * the hybrid / economy refs) — whether it is a *valid* region key, and whether + * its region ends the turn with a sole owner, is checked at apply time against + * the manifest and the final ownership (a project on a region with no sole + * owner yields no boost but is kept). A missing / malformed top-level object + * yields `{ projects: [] }`. + */ +export function validateIndustryOps(raw: unknown): IndustryOps { + const out: IndustryOps = { projects: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + if (Array.isArray(o.projects)) { + for (const e of o.projects) { + if (!e || typeof e !== 'object') continue; + const p = e as Record; + const regionKey = str(p.regionKey, 120); + if (!regionKey || !isIndustryType(p.type)) continue; + out.projects.push({ regionKey, type: p.type }); + } + } + return out; +} + +/** + * (v0.36, #59) The BASE per-turn economy boost one industry project grants its + * region's sole owner. A project's actual boost is this base plus one extra + * point when the region's `industry` resource value is at least 0.5 (a region + * with a strong industry profile yields a bigger return). The base value is + * deliberately the same magnitude as {@link TRADE_ECONOMY_BONUS} / + * {@link EMBARGO_ECONOMY_PENALTY}. + */ +const INDUSTRY_ECONOMY_BONUS = 1; + +/** + * (v0.36, #59) The per-turn economy boost for ONE industry project sitting in + * `regionKey`: {@link INDUSTRY_ECONOMY_BONUS} plus one extra point when the + * region's `industry` resource value (from `resourceOf`) is at least 0.5. + * Without the resource manifest (or for a region with no profile) the boost is + * the base value. Pure + deterministic. + */ +function industryBoost( + regionKey: string, + resourceOf?: (key: string) => RegionResources | undefined, +): number { + const r = resourceOf?.(regionKey); + return INDUSTRY_ECONOMY_BONUS + (r && r.industry >= 0.5 ? 1 : 0); +} + +/** + * (v0.36, #59) Apply the turn's industry ops to `GameState.industry` as a + * PER-REGION SET: each op names a region key + a project type. A region key + * that is not a valid manifest key (`validKeys`, when supplied) is dropped; a + * region that already hosts a project is untouched (the first declared type + * wins — re-declaring on the same region is a no-op, the type is never + * replaced). Otherwise the project is appended. Mutates `next` in place. + * Nothing here throws. + */ +function applyIndustryOps( + next: GameState, + ops: IndustryOps, + validKeys?: ReadonlySet, +): void { + if (ops.projects.length === 0) return; + const has = (key: string) => next.industry.some((p) => p.regionKey === key); + for (const p of ops.projects) { + if (validKeys && !validKeys.has(p.regionKey)) continue; // unknown region key: drop + if (has(p.regionKey)) continue; // a region hosts at most one project + next.industry.push({ regionKey: p.regionKey, type: p.type }); + } +} + +/** + * (v0.36, #59) The per-turn economic effect of the industry layer, applied + * EVERY committed turn (not just to this turn's adds): each active project + * grants its region's SOLE owner a small, deterministic economy boost (see + * {@link industryBoost}) — clamped to 0–100, mirrored to every region the + * owner holds (`regionStats`), and each affected nation's derived stats are + * re-derived afterwards (the same 0-region / no-entry guards as + * `applyEconomyEffects`). A project whose region ends the turn with NO owner, + * or 2+ owners (released to no man's land / corrupt), yields no boost but is + * kept in the list. Sole ownership is untouched. No-op when there are no + * active projects. Never throws. + */ +export function applyIndustryEffects( + next: GameState, + resourceOf?: (key: string) => RegionResources | undefined, + geoOf?: (key: string) => { area: number; population: number } | undefined, +): void { + if (next.industry.length === 0) return; + + // Map each region key to its SOLE owner (a key with 0 or 2+ owners maps to + // nothing and yields no boost). + const ownerOf = new Map(); + { + const owners = new Map(); + for (const n of Object.values(next.nations)) { + for (const key of n.territory) { + const list = owners.get(key); + if (list) list.push(n.id); + else owners.set(key, [n.id]); + } + } + for (const [key, list] of owners) { + if (list.length === 1) ownerOf.set(key, list[0]); + } + } + + // nation id -> accumulated economy boost (a nation owning several project + // regions gets each of their boosts). + const delta = new Map(); + for (const p of next.industry) { + const owner = ownerOf.get(p.regionKey); + if (!owner) continue; // 0 or 2+ owners: no boost (the project is kept) + const n = next.nations[owner]; + if (!n) continue; // a nation removed this turn (a merge): the boost is dropped + delta.set(owner, (delta.get(owner) ?? 0) + industryBoost(p.regionKey, resourceOf)); + } + + const affected = new Set(); + for (const [id, d] of delta) { + const n = next.nations[id]; + if (!n) continue; + n.stats.economy = clampStat(n.stats.economy + d); + 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); + } + } + } +} + +export const industry: Ability<'industry'> = { + field: 'industry', + default: () => ({ projects: [] }), + validate: (raw) => validateIndustryOps(raw), + apply: (state, data, ctx) => { + // Placed after the economy block (both move stats, and the industry boost + // should compose with the trade/embargo effects of the same turn) and + // before markers / nationOps. Order: + // (1) APPLY OPS: this turn's `industry.projects` are added to + // `GameState.industry` as a per-region SET — a region key that is not + // a valid manifest key is dropped, and a region that already hosts a + // project is untouched (the first declared type wins; re-declaring is + // a no-op). + // (2) PER-TURN EFFECT: the FULL active project list (not just this + // turn's adds) each grants their region's SOLE owner a small, + // deterministic economy boost scaled by the region's `industry` + // resource value — clamped, mirrored to every region the owner holds, + // and re-derived like the economy/hybrid effects. A project whose + // region ends the turn with no owner (or 2+ owners) yields no boost + // but is KEPT (the region may be re-claimed later). Sole ownership is + // untouched (the layer moves stats, never territory). + applyIndustryOps(state, data, ctx.validRegionKeys); + applyIndustryEffects(state, ctx.resourceOf, ctx.geoOf); + }, + schemaLine: () => + ' "industry": {"projects":[{"regionKey":"","type":""}]},', + constraint: () => + '- Industry ("industry") is the per-resource development lever: each project is a building in one region you (or a nation) controls — {"regionKey":"","type":""} (an oil facility, an iron mine, a food operation, or a factory). Every committed turn each active project gives its region\'s owner a small, deterministic economy gain, the bigger the region\'s industry profile (see the "resources" field). A region holds at most one project — do not re-declare one on a region that already has one (the user message\'s "industry" field lists what is already built). Propose at most 1-2 new projects per turn and narrate each one in "events".', + isMeaningful: (data) => data.projects.length > 0, + stateFields: ['industry'], + stub: () => ({ projects: [] }), + payload: (ctx) => { + const out: Record = {}; + if (ctx.nationResources !== undefined) out.resources = ctx.nationResources; + if (ctx.industryProjects !== undefined) out.industry = ctx.industryProjects; + return out; + }, +}; diff --git a/server/abilities/leaders.ts b/server/abilities/leaders.ts new file mode 100644 index 0000000..72f8cd7 --- /dev/null +++ b/server/abilities/leaders.ts @@ -0,0 +1,156 @@ +// The NAMED-LEADERS ability (v0.38, #61) as a modular LLM-ability (v0.40.0, +// #67). The model may change a nation's head of state: an `assassination` +// (a dramatic, destabilizing blow — a −15 stability dip, a "military" event, +// only valid when the target already has a leader), a `death` (a natural- +// causes succession — a quieter −3 dip, a "political" event, also only valid +// for a nation that has a leader), or an `appointed` change (sets or creates +// a leader and is always valid — a neutral 0 effect, a "political" event). +// On a valid change the target's `leader` is set, the DETERMINISTIC stability +// dip (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability` +// (clamped) and mirrored to every region the nation owns (`regionStats`), the +// nation's derived stats are re-derived afterwards (the same 0-region / +// no-entry guards), and a narration event is appended. The replacement never +// moves territory (sole ownership is untouched). All bodies are moved +// VERBATIM from the pre-refactor engine.ts. + +import { deriveNationStats, makeEvent } from '../../shared/protocol'; +import { clampStat, LEADER_STABILITY_EFFECT } from '../../shared/protocol'; +import type { GameState } from '../../shared/types'; +import type { LeaderCause, LeaderChange } from '../../shared/protocol'; +import { resolveNationRef, str } from './shared'; +import type { Ability } from './types'; + +/** + * (v0.38, #61) Validate the raw `leaders` object of a turn into a safe + * `LeaderChange[]`. Defensive like the rest of the parser — never throws. + * Accepts a `{ changes: [...] }` object or a bare array (lenient). Each change + * needs a non-empty `target` (a nation ref, capped at 120 chars), a non-empty + * `name` (the new leader's name, capped at 120 chars), and a `cause` that is + * one of the three known literals (assassination / death / appointed). Refs + * stay raw here (resolved against the real state at apply time, like the wars + * / hybrid / nuclear ops); a leaderless assassination/death is dropped at + * apply time, never thrown. + */ +export function validateLeaderOps(raw: unknown): LeaderChange[] { + const out: LeaderChange[] = []; + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' + ? (raw as Record).changes + : undefined; + if (!Array.isArray(list)) return out; + for (const e of list) { + if (!e || typeof e !== 'object') continue; + const s = e as Record; + const target = str(s.target, 120); + const name = str(s.name, 120); + if (!target || !name) continue; + const cause = typeof s.cause === 'string' ? s.cause : ''; + if (cause === 'assassination' || cause === 'death' || cause === 'appointed') { + out.push({ target, name, cause: cause as LeaderCause }); + } + } + return out; +} + +/** + * (v0.38, #61) Apply a single leader change to `next` (in place). The target + * nation is resolved by the shared resolver (id first, then a case-insensitive + * unique name; an unknown or ambiguous ref is dropped, never thrown). A GATE + * then applies: an `assassination` or `death` requires the target to already + * have a leader (a leaderless target is dropped); an `appointed` change is + * always valid (it creates the first leader). On a valid change the target's + * `leader` is set to the new name and a DETERMINISTIC stability dip + * (`LEADER_STABILITY_EFFECT[cause]`) is applied to its `stats.stability` + * (clamped 0–100) and mirrored to every region the nation owns + * (`regionStats`); the nation's derived stats are re-derived afterwards (the + * same 0-region / no-entry guards as the hybrid/economy/industry effects, so a + * 0-region nation keeps its directly-mutated value). Finally a narration event + * is appended: a dramatic `military` event for an assassination, a `political` + * succession event for a death or an appointment. The replacement never moves + * territory (sole ownership is untouched). + */ +function applyLeaderChange( + next: GameState, + op: LeaderChange, + geoOf?: (key: string) => { area: number; population: number } | undefined, + actingTurn?: number, +): void { + const turn = actingTurn ?? next.turn; + const id = resolveNationRef(next, op.target); + if (id === null) return; // unknown / ambiguous target: drop, never throw + const n = next.nations[id]; + + // GATE: assassination / death require an existing leader; an appointment is + // always valid (it creates the first leader). + if (op.cause !== 'appointed' && !n.leader) return; + + const prevName = n.leader?.name; + n.leader = { name: op.name }; + + const delta = LEADER_STABILITY_EFFECT[op.cause]; + if (delta !== 0) { + n.stats.stability = clampStat(n.stats.stability + delta); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs.stability = clampStat(rs.stability + delta); + } + // Re-derive the nation's aggregate stats (the main recompute ran earlier + // this turn, so without this the dip would be washed out next turn). + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } + } + + const title = + op.cause === 'assassination' + ? `${prevName ?? 'The leader'} of ${n.name} is assassinated` + : op.cause === 'death' + ? `${prevName ?? 'The leader'} of ${n.name} dies` + : `${n.name} appoints ${op.name}`; + const text = + op.cause === 'assassination' + ? `${n.name}'s leader ${prevName ?? ''} was assassinated; ${op.name} takes power amid turmoil.` + : op.cause === 'death' + ? `${n.name}'s leader ${prevName ?? ''} has died; ${op.name} succeeds to power.` + : `${n.name} has appointed ${op.name} as its new leader.`; + next.events.push( + makeEvent( + { + nation: n.id, + type: op.cause === 'assassination' ? 'military' : 'political', + title, + text, + outcome: 'success', + }, + turn, + next.date, + ), + ); +} + +export const leaders: Ability<'leaders'> = { + field: 'leaders', + default: () => ({ changes: [] }), + // The low-level validator returns a bare list; the field's shape is the + // `{ changes }` object (mirrors the historical `result.leaders = { changes: ... }`). + validate: (raw) => ({ changes: validateLeaderOps(raw) }), + apply: (state, data, ctx) => { + // Placed after the nuclear block (both are the "heavier" levers) and + // before the markers block (markers recompute their owner from the FINAL + // post-claim ownership; leaders only touch stats, so ordering w.r.t. + // markers is immaterial, but keeping the levers grouped is clearer). Each + // change sets the target's leader, applies a deterministic stability dip + // by cause, and appends a narration event. A leaderless assassination/ + // death is dropped. + for (const op of data.changes) applyLeaderChange(state, op, ctx.geoOf, ctx.actingTurn); + }, + schemaLine: () => + ' "leaders": {"changes":[{"target":"","name":"","cause":""}]},', + constraint: () => + '- Leaders ("leaders") name each nation\'s current head of state and change it. Each change is {"target":"","name":"","cause":""}: "assassination" kills the current leader (a dramatic, destabilizing blow — a −15 stability dip, a "military" event) and is only valid when the target already has a leader; "death" is a natural-causes succession (a quieter −3 dip, a "political" event), also only valid for a nation that has a leader; "appointed" sets (or creates) a leader and is always valid (a neutral 0 effect, a "political" event). Propose at most 1-2 changes per turn and narrate each one in "events". This is the HEAVY leader lever — use the lighter "hybrid" {"type":"assassinate"} op (a −5 stability dip, no leader replacement) for a mere hit on a leader or region without changing who is in charge. The user message\'s "nations" field lists each nation\'s current "leader" (omitted when a nation has none).', + isMeaningful: (data) => data.changes.length > 0, + stub: () => ({ changes: [] }), + payload: () => ({}), +}; diff --git a/server/abilities/nuclear.ts b/server/abilities/nuclear.ts new file mode 100644 index 0000000..b81a720 --- /dev/null +++ b/server/abilities/nuclear.ts @@ -0,0 +1,336 @@ +// The NUCLEAR / WMD ability (v0.37, #60) as a modular LLM-ability (v0.40.0, #67). +// The model may name nations to ARM with the nuclear flag (`nuclear.arm`) and +// fire nuclear strikes (`nuclear.strikes`, from a capable nation at a nation or +// a region). The deterministic effects (devastating the target, MAD retaliation, +// ending the target's wars, forcing relations hostile, releasing struck regions +// to no man's land) are all applied here. The apply bodies are moved VERBATIM +// from the pre-refactor engine.ts; `NUCLEAR_COLLAPSE` / `NUCLEAR_RETALIATION` / +// `isNuclearCapable` / `makeEvent` / `deriveNationStats` come from shared/protocol. + +import { + clampStat, + deriveNationStats, + isNuclearCapable, + makeEvent, + NUCLEAR_COLLAPSE, + NUCLEAR_RETALIATION, + type NuclearOps, + type NuclearStatKey, +} from '../../shared/protocol'; +import type { GameState, Nation } from '../../shared/types'; +import { activeWarPairs } from './apply-helpers'; +import { resolveNationRef, resolveRegionKey, str } from './shared'; +import type { Ability } from './types'; + +const NUCLEAR_STATS: NuclearStatKey[] = ['economy', 'military', 'stability']; + +/** + * (v0.37, #60) Validate the raw `nuclear` object of a turn into a safe + * {@link NuclearOps}. Defensive like the rest of the parser — never throws. + * `arm`: non-empty nation refs (capped at 120 chars), deduped; a ref is + * resolved (id or name) at APPLY time, so an unknown ref is dropped there, not + * here. `strikes`: each needs a non-empty `from` (the striking nation ref) and + * a non-empty `target` (a nation ref OR a region key), each capped at 120 + * chars; a self-target (`from` and `target` the same ref) is dropped here. A + * missing / malformed top-level object yields `{ arm: [], strikes: [] }`. + */ +export function validateNuclearOps(raw: unknown): NuclearOps { + const out: NuclearOps = { arm: [], strikes: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + if (Array.isArray(o.arm)) { + for (const e of o.arm) { + const s = typeof e === 'string' ? e.trim() : ''; + if (s && !out.arm.includes(s)) out.arm.push(s.slice(0, 120)); + } + } + if (Array.isArray(o.strikes)) { + for (const e of o.strikes) { + if (!e || typeof e !== 'object') continue; + const s = e as Record; + const from = str(s.from, 120); + const target = str(s.target, 120); + if (!from || !target) continue; + if (from.toLowerCase() === target.toLowerCase()) continue; // self-target: drop + out.strikes.push({ from, target }); + } + } + return out; +} + +/** + * Devastate a nation's stats by a delta profile (collapse / retaliation): each + * of the three nuclear stat keys is shifted by the given delta (clamped) on + * BOTH the nation and every region it owns. Never throws. + */ +function collapseNationStats( + next: GameState, + nationId: string, + collapse: Record, +): void { + const n = next.nations[nationId]; + if (!n) return; + for (const k of NUCLEAR_STATS) { + const d = collapse[k]; + n.stats[k] = clampStat(n.stats[k] + d); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (rs) rs[k] = clampStat(rs[k] + d); + } + } +} + +/** + * Release a region key to no man's land, reusing the turn's release semantics: + * the key leaves its SOLE owner's territory, is marked in `GameState.unowned`, + * and its `regionStats` entry is deleted. A key with 0 or 2+ owners (or an + * already-unowned key) is left untouched. Never throws. + */ +function releaseRegionToNoMansLand(next: GameState, key: string): void { + if (next.unowned.includes(key)) return; // already no man's land + const owners = Object.values(next.nations).filter((n) => n.territory.includes(key)); + if (owners.length !== 1) return; // 0 or 2+ owners: not releasable + next.nations[owners[0]!.id].territory = next.nations[owners[0]!.id].territory.filter( + (k) => k !== key, + ); + next.unowned.push(key); + delete next.regionStats[key]; +} + +/** + * End every ACTIVE war whose sides include both `a` and `b` (a nuclear + * capitulation), then downgrade the `war` relation of every pair that is no + * longer covered by an active war back to `hostile` (the war → relation sync). + * Capturing the active pairs immediately BEFORE the end keeps this correct for + * a war declared and ended in the SAME turn (where the start-of-turn `prev` + * that `syncWarRelations` uses would not contain it). Never throws. + */ +function endWarsBetween(next: GameState, a: string, b: string): void { + const before = activeWarPairs(next.wars); + let ended = false; + for (const w of next.wars) { + if (w.status === 'active' && w.sides.includes(a) && w.sides.includes(b)) { + w.status = 'capitulation'; + ended = true; + } + } + if (!ended) return; + const after = activeWarPairs(next.wars); + for (const key of before) { + if (after.has(key)) continue; // still covered by another active war + const [x, y] = key.split('\u0000'); + const nx = next.nations[x]; + const ny = next.nations[y]; + if (!nx || !ny) continue; + if (nx.relations[y] === 'war' || ny.relations[x] === 'war') { + nx.relations[y] = 'hostile'; + ny.relations[x] = 'hostile'; + } + } +} + +/** + * Force the relation between two nations to `hostile` in BOTH directions — + * the aftermath of a MAD exchange. A `war` relation is never downgraded (the + * war entity owns it while active — e.g. a region strike that devastates one + * warring side without ending the war). Never throws. + */ +function forceHostile(next: GameState, a: string, b: string): void { + const na = next.nations[a]; + const nb = next.nations[b]; + if (!na || !nb) return; + if (na.relations[b] !== 'war') na.relations[b] = 'hostile'; + if (nb.relations[a] !== 'war') nb.relations[a] = 'hostile'; +} + +/** + * Apply a turn's nuclear ops to the (cloned) `next` state, mutating it in + * place and never throwing. See the section comment above for the full + * contract. `state` is the ORIGINAL (pre-turn) state — its `markers` supply the + * "owns a rocket" half of nuclear capability (a rocket marker placed this very + * turn makes its owner capable only from the next turn, while a nation that + * already owns one this turn is capable now). + */ +export function applyNuclearOps( + next: GameState, + ops: NuclearOps, + state: GameState, + geoOf?: (key: string) => { area: number; population: number } | undefined, + validKeys?: ReadonlySet, + regionNameOf?: (key: string) => string | undefined, + actingTurn?: number, +): void { + if (ops.arm.length === 0 && ops.strikes.length === 0) return; + const turn = actingTurn ?? next.turn; + const markers = state.markers ?? []; + const capable = (id: string): boolean => { + const n = next.nations[id]; + return n ? isNuclearCapable(n, markers) : false; + }; + + // (1) ARM: set the nuclear flag (idempotent); unresolvable refs are dropped. + for (const ref of ops.arm) { + const id = resolveNationRef(next, ref); + if (id === null) continue; + const n = next.nations[id]; + if (!n) continue; + n.nuclear = true; + } + + const affected = new Set(); + const regionDisplay = (key: string): string => regionNameOf?.(key) ?? key; + + // (2) STRIKES: each validated independently against the post-arm state. + for (const strike of ops.strikes) { + const fromId = resolveNationRef(next, strike.from); + if (fromId === null) continue; // unknown / ambiguous striker: drop + const from = next.nations[fromId]; + if (!from) continue; + if (!capable(fromId)) continue; // a non-capable nation cannot strike + if (fromId === resolveNationRef(next, strike.target)) continue; // self: drop + + // Resolve the target: a NATION (a ref resolving to a distinct nation) or a + // REGION (a region key / name with a sole owner that is not the striker). + let target: + | { kind: 'nation'; nation: Nation } + | { kind: 'region'; key: string; owner: Nation } + | null = null; + const tid = resolveNationRef(next, strike.target); + if (tid !== null) { + const tn = next.nations[tid]; + if (tn) target = { kind: 'nation', nation: tn }; + } else { + const rk = validKeys + ? resolveRegionKey(strike.target, validKeys, regionNameOf ?? (() => undefined)) + : (strike.target.trim() || null); + if (rk !== null) { + const owners = Object.values(next.nations).filter((n) => n.territory.includes(rk)); + if (owners.length === 1 && owners[0]!.id !== fromId) { + target = { kind: 'region', key: rk, owner: owners[0]! }; + } + // a 0/2+-owner region, or a region the striker owns (self), is dropped + } + } + if (target === null) continue; // unresolvable target: drop + + if (target.kind === 'nation') { + // Devastate the target nation. + collapseNationStats(next, target.nation.id, NUCLEAR_COLLAPSE); + affected.add(target.nation.id); + // A strike on a nation at war with the striker ends that war in + // capitulation (the relation then falls back to hostile). + endWarsBetween(next, fromId, target.nation.id); + // MAD: a nuclear-capable target strikes back at half strength and the + // two nations end the exchange at least hostile. + if (capable(target.nation.id)) { + collapseNationStats(next, fromId, NUCLEAR_RETALIATION); + affected.add(fromId); + forceHostile(next, fromId, target.nation.id); + next.events.push( + makeEvent( + { + nation: fromId, + type: 'military', + title: 'MAD retaliation', + text: `${target.nation.name} launches a retaliatory nuclear strike against ${from.name}; mutual assured destruction devastates ${from.name} in turn.`, + outcome: 'failure', + }, + turn, + next.date, + ), + ); + } + next.events.push( + makeEvent( + { + nation: fromId, + type: 'military', + title: `Nuclear strike on ${target.nation.name}`, + text: `${from.name} devastates ${target.nation.name} with a nuclear strike; the target's economy, military and stability collapse.`, + outcome: 'success', + }, + turn, + next.date, + ), + ); + } else { + // Devastate the region: release it to no man's land, then devastate its + // former owner. + const owner = target.owner; + releaseRegionToNoMansLand(next, target.key); + collapseNationStats(next, owner.id, NUCLEAR_COLLAPSE); + affected.add(owner.id); + // MAD: a nuclear-capable owner strikes back at half strength and the + // two nations end the exchange at least hostile. + if (capable(owner.id)) { + collapseNationStats(next, fromId, NUCLEAR_RETALIATION); + affected.add(fromId); + forceHostile(next, fromId, owner.id); + next.events.push( + makeEvent( + { + nation: fromId, + type: 'military', + title: 'MAD retaliation', + text: `${owner.name} launches a retaliatory nuclear strike against ${from.name} in response to the devastation of ${regionDisplay( + target.key, + )}; mutual assured destruction devastates ${from.name} in turn.`, + outcome: 'failure', + }, + turn, + next.date, + ), + ); + } + next.events.push( + makeEvent( + { + nation: fromId, + type: 'military', + title: `Nuclear strike on ${regionDisplay(target.key)}`, + text: `${from.name} obliterates ${regionDisplay( + target.key, + )} with a nuclear strike, reducing the region to no man's land and devastating its former owner, ${owner.name}.`, + outcome: 'success', + }, + turn, + next.date, + ), + ); + } + } + + // (3) RE-DERIVE: every affected nation's aggregate stats (the same 0-region / + // no-entry guards as the main recompute), so the collapse persists. + for (const id of affected) { + const n = next.nations[id]; + if (!n) continue; + if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate + if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } +} + +export const nuclear: Ability<'nuclear'> = { + field: 'nuclear', + default: () => ({ arm: [], strikes: [] }), + validate: (raw) => validateNuclearOps(raw), + apply: (state, data, ctx) => { + applyNuclearOps( + state, + data, + ctx.previous, + ctx.geoOf, + ctx.validRegionKeys, + ctx.regionNameOf, + ctx.actingTurn, + ); + }, + schemaLine: () => + ' "nuclear": {"arm":[""],"strikes":[{"from":"","target":""}]},', + constraint: () => + '- Nuclear / WMD ("nuclear"): a nation is "nuclear-capable" when its "nuclear" flag is true or it currently owns a "rocket" marker. "arm" names (by id or name) nations to grant the "nuclear" flag — idempotent (already-armed nations are unchanged); a newly armed nation is capable this same turn. "strikes" is a list of "NuclearStrike" objects, each "{from, target}" — "from" is the striking nation (must be nuclear-capable and never itself) and "target" is either a nation (its nation collapses: economy -40, military -50, stability -40; every active war the target fights ends by capitulation and the "war" relations it caused downgrade to "hostile") or a region key (that region is released to no man\'s land, its regionStats entry deleted, and its sole owner collapses the same way — a region with 0 or 2+ owners, or one the striker itself owns, is dropped). If the struck nation (or the struck region\'s owner) is itself nuclear-capable, a "MAD" retaliation is applied: it strikes the striker back for half the collapse, and the two nations\' relations are forced to "hostile". At most one strike per turn; always narrate a strike (and a retaliation, if any) in "events".', + isMeaningful: (data) => data.arm.length > 0 || data.strikes.length > 0, + stub: () => ({ arm: [], strikes: [] }), + payload: () => ({}), +}; diff --git a/server/abilities/shared.ts b/server/abilities/shared.ts index 7a2705f..cd131f2 100644 --- a/server/abilities/shared.ts +++ b/server/abilities/shared.ts @@ -94,3 +94,16 @@ export const NEW_NATION_STATS: NationStats = { stability: 50, population: 50, }; + +/** + * Coerce an unknown LLM JSON value to a trimmed, non-empty string, or `null` + * when it is not a non-empty string. An optional `max` caps the length (used + * for the 200-char caps on nationOp fields). Shared by every defensive + * validator (engine + the ability modules). Never throws. + */ +export function str(s: unknown, max = 0): string | null { + if (typeof s !== 'string') return null; + const t = s.trim(); + if (!t) return null; + return max ? t.slice(0, max) : t; +} diff --git a/server/abilities/takeovers.ts b/server/abilities/takeovers.ts new file mode 100644 index 0000000..960e65d --- /dev/null +++ b/server/abilities/takeovers.ts @@ -0,0 +1,100 @@ +// The TAKEOVER ability (v0.32.0, #54) as a modular LLM-ability (v0.40.0, #67). +// The model may overthrow a low-stability nation hosting a "gathering" marker +// (a coup / revolution) — an AUTOMATIC, non-military regime change that RENAMES +// the nation to its new regime (the stable id, territory, relations, stats and +// playerNationId all survive). The validator and the apply body are moved +// VERBATIM from the pre-refactor engine.ts; the user-payload `takeoverRisks` +// hint (v0.30, #50 — the bounded soft-lever nudge listing the nations ripe for +// a takeover) moves here too, since it is this ability's own prompt data. + +import type { GameState } from '../../shared/types'; +import type { Takeover } from '../../shared/protocol'; +import { resolveNationRef, str } from './shared'; +import type { Ability } from './types'; + +/** + * (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe + * {@link Takeover} list — the non-military regime-change lever (a coup or + * revolution that renames a nation to its new regime name). Defensive like + * the rest of the parser — never throws. Each entry needs a non-empty + * `nationRef` (the target nation's id or name — capped at 200 chars) and a + * non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef` + * to a concrete nation id happens at apply time (`resolveNationRef`), not + * here. Entries with either field missing or whitespace-only are dropped. + */ +export function validateTakeovers(raw: unknown): Takeover[] { + if (!Array.isArray(raw)) return []; + const out: Takeover[] = []; + for (const e of raw) { + if (!e || typeof e !== 'object') continue; + const o = e as Record; + const nationRef = str(o.nationRef, 200); + const newRegimeName = str(o.newRegimeName, 200); + if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName }); + } + return out; +} + +/** + * (v0.32.0, #54) Apply a takeover to `next` (in place). For each takeover: + * 1. Resolve the target nation via `resolveNationRef` (id first, then + * case-insensitive unique name). Skip (no-op) if `null` — an + * unknown or ambiguous ref is silently dropped, never thrown. + * 2. Rename the resolved nation: set `n.name = newRegimeName`. Only + * the display name changes — the stable id, territory, relations + * (keyed by id), stats, and `playerNationId` (which references the + * id, not the name) are all preserved. The player keeps leading an + * overthrown nation under its new regime. + */ +export function applyTakeover(next: GameState, t: Takeover): void { + const id = resolveNationRef(next, t.nationRef); + if (id === null) return; // unknown / ambiguous ref: no-op + const n = next.nations[id]; + if (!n) return; // defensive: id resolved but nation missing (corrupt state) + n.name = t.newRegimeName; +} + +export const takeovers: Ability<'takeovers'> = { + field: 'takeovers', + default: () => [], + validate: (raw) => validateTakeovers(raw), + apply: (state, data) => { + // Applied after the per-region recompute (the rename doesn't depend on + // territory or stats, so it is order-independent with respect to those + // steps) and before the markers block (a marker's owner is a nation id, + // not a name, so the rename does not affect the owner recompute). + for (const t of data) applyTakeover(state, t); + }, + schemaLine: () => + ' "takeovers": [{"nationRef":"","newRegimeName":""}],', + constraint: () => + '- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"","newRegimeName":""}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.', + isMeaningful: (data) => data.length > 0, + stub: () => [], + payload: (ctx) => { + // (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30 + // AND that currently have a "gathering" marker on one of their regions are + // the ones ripe for a NON-military takeover (a coup / revolution / faction + // seizure) — the model can act on them this turn. One line per matching + // nation, emitted only when there is at least one; the rest of the payload + // is left untouched. A soft nudge only — there is no hard gate behind it. + let takeoverRisks: string[] | undefined; + if (ctx.markers !== undefined && ctx.markers.length > 0) { + const risks = new Set(); + for (const m of ctx.markers) { + if (m.type !== 'gathering') continue; + const ownerId = m.owner; + if (!ownerId) continue; + const n = ctx.state.nations[ownerId]; + if (n && n.stats.stability < 30) risks.add(n.id); + } + if (risks.size > 0) { + takeoverRisks = [...risks].map((id) => { + const n = ctx.state.nations[id]; + return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`; + }); + } + } + return takeoverRisks !== undefined ? { takeoverRisks } : {}; + }, +}; diff --git a/server/abilities/wars.ts b/server/abilities/wars.ts new file mode 100644 index 0000000..1d1c982 --- /dev/null +++ b/server/abilities/wars.ts @@ -0,0 +1,156 @@ +// The WARS ability (v0.33.0, #56) as a modular LLM-ability (v0.40.0, #67). +// Wars are named first-class conflicts (`GameState.wars`), not just a `war` +// relation: the model declares a war (≥2 belligerents, optional aims) or ends +// one by id (armistice / capitulation). Declaring drags in each belligerent's +// mutual-defense allies; the derived `war` relation is synced from the ACTIVE +// wars by `syncWarRelations` (a war-ended pair falls back to `hostile`). The +// validator and the apply body are moved VERBATIM from the pre-refactor +// engine.ts. + +import type { GameState } from '../../shared/types'; +import type { TurnResult, WarDeclareOp, WarEndOp } from '../../shared/protocol'; +import { freshWarId, resolveNationRef } from './shared'; +import { syncWarRelations } from './apply-helpers'; +import type { Ability } from './types'; + +/** + * (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe + * `{ declare, end }` shape. Defensive like the rest of the parser — never + * throws. `declare`: each entry must be an object whose `sides` is an array + * of non-empty strings (trimmed, capped at 200 chars each, deduped) that + * yields at least TWO distinct sides — anything else (a one-sided or + * empty-sided "war") is dropped; `aims`, when an array, is kept only as + * non-empty strings (capped at 200 chars, at most 5). `end`: each entry must + * be an object with a non-empty `id` (capped at 200 chars) and an `outcome` + * of exactly `armistice` or `capitulation` (anything else is dropped); + * `terms`, when a non-empty string, is capped at 400 chars. A non-object + * input yields `{ declare: [], end: [] }`. + */ +export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } { + const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + if (Array.isArray(o.declare)) { + for (const e of o.declare) { + if (!e || typeof e !== 'object') continue; + const d = e as Record; + const sides = (Array.isArray(d.sides) ? d.sides : []) + .map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : '')) + .filter((s) => s !== '') + // dedupe case-insensitively? No — sides are nation ids: keep exact + // strings, drop exact duplicates only (resolution is later). + .filter((s, i, arr) => arr.indexOf(s) === i); + if (sides.length < 2) continue; // a war needs at least two belligerents + const aims = Array.isArray(d.aims) + ? (d.aims as unknown[]) + .map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : '')) + .filter((a) => a !== '') + .slice(0, 5) + : undefined; + out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) }); + } + } + if (Array.isArray(o.end)) { + for (const e of o.end) { + if (!e || typeof e !== 'object') continue; + const w = e as Record; + const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : ''; + if (!id) continue; + if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue; + const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : ''; + out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) }); + } + } + return out; +} + +/** + * (v0.33.0, #56) Apply the turn's wars ops to the (cloned) `next` state, + * mutating it in place and never throwing. Two kinds of change, applied in + * this order against `next`: + * + * (1) DECLARE: each validated declare is resolved to concrete nation ids + * (`resolveNationRef`, so the LLM may use an id OR a unique name) and + * becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped + * with the acting turn and the turn's new date. Mutually-defensive + * allies of the belligerents are DRAGGED IN: any nation that is an + * `ally` of a belligerent (per the current, post-relations relations) + * and is not already a side joins the war's `sides`. A declare with + * fewer than two DISTINCT resolved sides is dropped; a declare whose + * sides are all already contained in an existing ACTIVE war is a + * duplicate and is skipped (the existing war stands). + * (2) END: each validated end targets a war BY ID and sets its status to + * the outcome (`armistice` / `capitulation`). Only ACTIVE wars are + * affected — an unknown id or an already-ended war is a no-op. + * (A war may be declared and ended in the SAME turn: declare runs + * first, so the just-declared id is a valid end target.) + * (3) RELATION SYNC: `syncWarRelations(prev, next)` derives the `war` + * relation from the ACTIVE wars — the load-bearing step that keeps + * the relation table honest with the war entities. `prev` is the + * state this turn started from (ctx.previous). + * + * Nothing here throws: every unresolvable / invalid op is dropped. + */ +export function applyWars( + next: GameState, + data: TurnResult['wars'], + prev: GameState, + actingTurn: number, +): void { + const takenWarIds = new Set(next.wars.map((w) => w.id)); + // (1) DECLARE + for (const d of data.declare) { + const resolved: string[] = []; + for (const ref of d.sides) { + const id = resolveNationRef(next, ref); + if (id !== null && !resolved.includes(id)) resolved.push(id); + } + if (resolved.length < 2) continue; // not enough distinct belligerents + // Duplicate: an active war already contains all of these sides. + const dup = next.wars.some( + (w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)), + ); + if (dup) continue; + const sides = [...resolved]; + // Mutual-defense drag-in: each belligerent's allies join the war. + for (const s of resolved) { + const rels = next.nations[s]?.relations ?? {}; + for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) { + if (!sides.includes(ally)) sides.push(ally); + } + } + const id = freshWarId(takenWarIds); + takenWarIds.add(id); + next.wars.push({ + id, + sides, + declaredTurn: actingTurn, + declaredDate: next.date, + ...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}), + status: 'active', + }); + } + // (2) END (by id; active only) + for (const e of data.end) { + const w = next.wars.find((w) => w.id === e.id && w.status === 'active'); + if (!w) continue; // unknown id / already ended: no-op + w.status = e.outcome; + } + // (3) RELATION SYNC (prev = the state this turn started from) + syncWarRelations(prev, next); +} + +export const wars: Ability<'wars'> = { + field: 'wars', + default: () => ({ declare: [], end: [] }), + validate: (raw) => validateWarOps(raw), + apply: (state, data, ctx) => applyWars(state, data, ctx.previous, ctx.actingTurn), + schemaLine: () => + ' "wars": {"declare":[{"sides":["",""],"aims":[""]}],"end":[{"id":"","outcome":"","terms":""}]},', + constraint: () => + '- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["",""],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"","outcome":""}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".', + isMeaningful: (data) => data.declare.length > 0 || data.end.length > 0, + stateFields: ['wars'], + stub: () => ({ declare: [], end: [] }), + payload: (ctx) => (ctx.wars !== undefined ? { wars: ctx.wars } : {}), +};