feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
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.
This commit is contained in:
2026-10-17 16:54:28 +00:00
parent 0a6c5aef9c
commit 283f9e0306
26 changed files with 1493 additions and 49 deletions
+172
View File
@@ -0,0 +1,172 @@
// 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 ?? [] }),
};