// 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'; } } } }