feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
CI / build + unit + e2e (pull_request) Successful in 2m20s
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:
@@ -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 ?? [] }),
|
||||
};
|
||||
Reference in New Issue
Block a user