CI / build + unit + e2e (pull_request) Successful in 2m20s
#80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides). #81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn). Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing.
173 lines
7.9 KiB
TypeScript
173 lines
7.9 KiB
TypeScript
// 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>): 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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":"<nation id or name>","b":"<nation id or name>"}],"break":[{"a":"<nation id or name>","b":"<nation id or name>"}]},',
|
|
constraint: () =>
|
|
'- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"<nation>","b":"<nation>"}) 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 ?? [] }),
|
|
};
|