// The ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) as a modular // LLM-ability (v0.40.0, #67). A durable, symmetric pact between two distinct // nations: the LLM proposes `form` / `break` ops each turn and the engine // applies them directly (against the post-territory `next` state) and persists // the active pacts in `GameState.alliances`. // // A pair holds at most one pact (re-forming is a no-op; breaking a non- // existent pact is a no-op). Forming a pact sets the pair's relation to // `ally`; breaking one sets it back to `neutral`. In both cases an ACTIVE WAR // between the pair overrides: the pact still forms / still breaks, but the // `war` relation stays in force — the layer never makes peace on its own. // Alliances never move borders (sole ownership is untouched) and are // deterministic (no hidden randomness — the LLM declares, the engine applies // fixed rules). // // This is a DURABLE ability (unlike the turn-transient hybrid / disasters / // migration layers): it owns the `GameState.alliances` field, and the user // payload ALWAYS carries the current pacts (even when empty) so the model // knows who is allied with whom. import { atWar } from './apply-helpers'; import type { Alliance, AllianceOps } from '../../shared/protocol'; import type { GameState } from '../../shared/types'; import { resolveNationRef, str } from './shared'; import type { Ability, AbilityCtx } from './types'; /** * (v0.45.0, #80) Generate a fresh, unused alliance id: the first free of * `al1`, `al2`, .... The caller adds each id it creates to `taken` as it goes, * so ids stay unique within a turn and are never reused from a removed pact. */ export function freshAllianceId(taken: ReadonlySet): string { for (let i = 1; ; i++) { const id = `al${i}`; if (!taken.has(id)) return id; } } /** * (v0.45.0, #80) The two nation ids of an alliance entry, sorted (so a pair * can be looked up regardless of the order the LLM names them in). */ function sortedParties(a: string, b: string): [string, string] { return a < b ? [a, b] : [b, a]; } /** * (v0.45.0, #80) Find the index of the pact (if any) covering the pair `a` / * `b`, order-independent. Returns -1 when no such pact exists. */ function pactIndex(alliances: Alliance[], a: string, b: string): number { const [x, y] = sortedParties(a, b); return alliances.findIndex((c) => c.parties[0] === x && c.parties[1] === y); } /** * (v0.45.0, #80) Validate the raw `alliances` field of a turn into a safe * {@link AllianceOps}. Defensive like the rest of the parser — never throws. * Accepts the object `{ form: [...], break: [...] }` (lenient: a bare object * with `form`/`break` arrays). Each op `{a, b}` needs non-empty `a` and `b` * (each capped at 200 chars) with `a !== b`; a malformed op is dropped. * Resolution of `a`/`b` to concrete nation ids happens at apply time * (`applyAlliances`), not here. A missing / malformed top-level value yields * `{ form: [], break: [] }`. */ export function validateAlliancesOps(raw: unknown): AllianceOps { const out: AllianceOps = { form: [], break: [] }; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; const o = raw as Record; for (const key of ['form', 'break'] as const) { const list = o[key]; if (!Array.isArray(list)) continue; for (const e of list) { if (!e || typeof e !== 'object') continue; const p = e as Record; const a = str(p.a, 200); const b = str(p.b, 200); if (!a || !b || a === b) continue; // empty / missing / self-pairing: drop out[key].push({ a, b }); } } return out; } /** * (v0.45.0, #80) Apply the turn's alliance ops to `next`, mutating in place * and never throwing. `break` is processed FIRST, then `form`, so a turn that * breaks and re-forms the same pair ends with a fresh pact. An unresolvable * ref (unknown / ambiguous nation) drops that op. * * (1) BREAK: for each op, if both refs resolve to distinct nations and a pact * covers the pair, remove the pact and set a↔b to `neutral` — UNLESS the * pair is at war (an active war overrides: the pact breaks but the * `war` relation stays). * (2) FORM: for each op, if both refs resolve to distinct nations and no pact * covers the pair, create `{ id, parties: [sorted a, b], formedTurn, * formedDate }` and set a↔b to `ally` — UNLESS the pair is at war (the * pact still forms, but the `war` relation stays in force). Re-forming an * existing pact is a no-op. * * Sole ownership is never touched; only `alliances` and the pair's mutual * relation change. */ export function applyAlliances( next: GameState, ops: AllianceOps, ctx: AbilityCtx, ): void { const alliances = next.alliances ?? []; const wars = next.wars ?? []; // (1) BREAK first: end existing pacts. for (const op of ops?.break ?? []) { const a = resolveNationRef(next, op.a); const b = resolveNationRef(next, op.b); if (!a || !b || a === b) continue; const i = pactIndex(alliances, a, b); if (i === -1) continue; // no pact to break: no-op alliances.splice(i, 1); // Set the pair back to neutral, unless an active war still binds them. if (!atWar(wars, a, b)) { const na = next.nations[a]; const nb = next.nations[b]; if (na) na.relations[b] = 'neutral'; if (nb) nb.relations[a] = 'neutral'; } } // (2) FORM: create new pacts (idempotent — re-forming is a no-op). const taken = new Set(alliances.map((c) => c.id)); for (const op of ops?.form ?? []) { const a = resolveNationRef(next, op.a); const b = resolveNationRef(next, op.b); if (!a || !b || a === b) continue; if (pactIndex(alliances, a, b) !== -1) continue; // already allied: no-op const [x, y] = sortedParties(a, b); alliances.push({ id: freshAllianceId(taken), parties: [x, y], formedTurn: ctx.actingTurn, formedDate: next.date, }); taken.add(alliances[alliances.length - 1].id); // Set the pair to ally, unless an active war still binds them. if (!atWar(wars, a, b)) { const na = next.nations[a]; const nb = next.nations[b]; if (na) na.relations[b] = 'ally'; if (nb) nb.relations[a] = 'ally'; } } next.alliances = alliances; } export const alliances: Ability<'alliances'> = { field: 'alliances', default: () => ({ form: [], break: [] }), validate: (raw) => validateAlliancesOps(raw), apply: (state, data, ctx) => applyAlliances(state, data, ctx), schemaLine: () => ' "alliances": {"form":[{"a":"","b":""}],"break":[{"a":"","b":""}]},', constraint: () => '- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"","b":""}) creates a pact and sets the pair\'s relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message\'s "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).', isMeaningful: (data) => data.form.length > 0 || data.break.length > 0, stateFields: ['alliances'], stub: () => ({ form: [], break: [] }), // Alliances are DURABLE (like sea / wars / industry): the user payload // ALWAYS carries the current pacts (even when empty) so the model knows who // is allied with whom and can `break` a pact or avoid re-forming one. payload: (ctx) => ({ alliances: ctx.state.alliances ?? [] }), };