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.
216 lines
10 KiB
TypeScript
216 lines
10 KiB
TypeScript
// The SPACE PROGRAM / satellites ability (v0.45.0, #81) as a modular
|
||
// LLM-ability (v0.40.0, #67). Durable per-turn space ops: the LLM launches
|
||
// satellites (one of four `SatelliteType`s) over regions it owns and deorbits
|
||
// active ones by id. Every active satellite grants its owner a deterministic
|
||
// +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to
|
||
// every region the owner holds (`regionStats`), then the owner's derived
|
||
// `stats` are re-derived — so a nation with several satellites stacks the
|
||
// bonus. Launches/deorbits never move borders (sole ownership is untouched).
|
||
//
|
||
// Each turn the LLM may `launch` new satellites (each op names `by`, a valid
|
||
// region key owned by `by`, and a type) and/or `deorbit` active satellites
|
||
// (by their stable id). A launch is dropped when `by` does not resolve, when
|
||
// `regionKey` is not a valid key owned by `by`, or when `type` is not a known
|
||
// `SatelliteType`; a deorbit of an unknown id is a no-op. New satellites get
|
||
// a fresh id (`s1`, `s2`, …) and are stamped with `launchedTurn` /
|
||
// `launchedDate`.
|
||
//
|
||
// This is a DURABLE ability (unlike the turn-transient hybrid / disasters /
|
||
// migration layers): it owns the `GameState.satellites` field, and the user
|
||
// payload ALWAYS carries the current satellite list (even when empty) so the
|
||
// model knows what is in orbit and can deorbit it.
|
||
|
||
import { clampStat, deriveNationStats, isSatelliteType } from '../../shared/protocol';
|
||
import type { SpaceOps } from '../../shared/protocol';
|
||
export { SATELLITE_TYPES, isSatelliteType } from '../../shared/protocol';
|
||
import type { GameState, Nation } from '../../shared/types';
|
||
import { resolveNationRef, str } from './shared';
|
||
import type { Ability, AbilityCtx } from './types';
|
||
|
||
/**
|
||
* (v0.45.0, #81) The deterministic per-turn stability bonus each active
|
||
* satellite grants its owner (clamped to 0–100, mirrored to every region the
|
||
* owner holds, then the owner's derived `stats` re-derived). A nation with
|
||
* several satellites stacks the bonus (one `+1` per satellite).
|
||
*/
|
||
export const SATELLITE_STABILITY_BONUS = 1;
|
||
|
||
/**
|
||
* (v0.45.0, #81) Generate a fresh, unused satellite id: the first free of
|
||
* `s1`, `s2`, .... The caller adds each id it creates to `taken` as it goes.
|
||
*/
|
||
export function freshSatelliteId(taken: ReadonlySet<string>): string {
|
||
for (let i = 1; ; i++) {
|
||
const id = `s${i}`;
|
||
if (!taken.has(id)) return id;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* (v0.45.0, #81) Validate the raw `space` field of a turn into a safe
|
||
* {@link SpaceOps}. Defensive like the rest of the parser — never throws.
|
||
* Accepts the object `{ launch: [...], deorbit: [...] }`. Each launch op
|
||
* needs a non-empty `by` (capped at 200 chars), a non-empty `regionKey`
|
||
* (capped at 120 chars), and a `type` that is one of {@link SATELLITE_TYPES};
|
||
* an invalid op is dropped. Each deorbit entry needs a non-empty id (capped
|
||
* at 120 chars); deorbit ids are deduped. Resolution of `by` to a concrete
|
||
* nation and the ownership check on `regionKey` happen at apply time
|
||
* (`applySpaceControl`), not here. A missing / malformed top-level value
|
||
* yields `{ launch: [], deorbit: [] }`.
|
||
*/
|
||
export function validateSpaceOps(raw: unknown): SpaceOps {
|
||
const out: SpaceOps = { launch: [], deorbit: [] };
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||
const o = raw as Record<string, unknown>;
|
||
|
||
if (Array.isArray(o.launch)) {
|
||
const seenLaunch = new Set<string>();
|
||
for (const e of o.launch) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const p = e as Record<string, unknown>;
|
||
const by = str(p.by, 200);
|
||
const regionKey = str(p.regionKey, 120);
|
||
if (!by || !regionKey) continue;
|
||
if (!isSatelliteType(p.type)) continue;
|
||
const id = `${by}\u0000${regionKey}\u0000${p.type}`;
|
||
if (seenLaunch.has(id)) continue; // dedupe identical launch ops
|
||
seenLaunch.add(id);
|
||
out.launch.push({ by, regionKey, type: p.type });
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(o.deorbit)) {
|
||
const seenDeorbit = new Set<string>();
|
||
for (const e of o.deorbit) {
|
||
const id = str(e, 120);
|
||
if (!id) continue; // empty / missing / non-string: drop
|
||
if (seenDeorbit.has(id)) continue; // dedupe
|
||
seenDeorbit.add(id);
|
||
out.deorbit.push(id);
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* (v0.45.0, #81) Remove the satellites whose ids are in `deorbit` from
|
||
* `next.satellites`. An absent id is a no-op.
|
||
*/
|
||
function deorbitSatellites(next: GameState, deorbit: string[]): void {
|
||
if (deorbit.length === 0) return;
|
||
const ids = new Set(deorbit);
|
||
next.satellites = (next.satellites ?? []).filter((s) => !ids.has(s.id));
|
||
}
|
||
|
||
/**
|
||
* (v0.45.0, #81) Launch this turn's satellites: resolve `by` (id first, then
|
||
* a case-insensitive unique name), require `regionKey` to be a valid key
|
||
* owned by `by`, and append a fresh `{ id, by, regionKey, type, launchedTurn,
|
||
* launchedDate }` per op. Unknown / ambiguous `by`, an unowned or invalid
|
||
* `regionKey`, or an unknown `type` (already filtered at validate time) drops
|
||
* the op. Never throws.
|
||
*/
|
||
function launchSatellites(next: GameState, ops: SpaceOps['launch'], ctx: AbilityCtx): void {
|
||
const validKeys = ctx.validRegionKeys;
|
||
const taken = new Set((next.satellites ?? []).map((s) => s.id));
|
||
for (const op of ops) {
|
||
const by = resolveNationRef(next, op.by);
|
||
if (!by) continue; // unresolvable launcher: drop
|
||
const key = op.regionKey;
|
||
if (validKeys && !validKeys.has(key)) continue; // not a valid region key: drop
|
||
const owner = next.nations[by];
|
||
if (!owner || !owner.territory.includes(key)) continue; // by does not own it: drop
|
||
next.satellites = (next.satellites ?? []).concat({
|
||
id: freshSatelliteId(taken),
|
||
by,
|
||
regionKey: key,
|
||
type: op.type,
|
||
launchedTurn: ctx.actingTurn,
|
||
launchedDate: next.date,
|
||
});
|
||
taken.add(next.satellites[next.satellites.length - 1].id);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* (v0.45.0, #81) The per-turn satellite bonus, applied EVERY committed turn
|
||
* (not just to this turn's launches): each active satellite grants its owner
|
||
* +`SATELLITE_STABILITY_BONUS` stability (clamped to 0–100), mirrored to every
|
||
* region the owner holds (`regionStats`), then the owner's derived `stats` are
|
||
* re-derived (the same 0-region / no-entry guards as the economy / migration
|
||
* layers — a 0-region nation keeps its directly-mutated value). A nation with
|
||
* several satellites stacks the bonus (one `+1` per satellite). A claim
|
||
* whose nation no longer exists (removed by a merge / split this turn) is
|
||
* skipped. Never throws.
|
||
*/
|
||
export function applySatelliteBonuses(next: GameState, geoOf?: (key: string) => { area: number; population: number } | undefined): void {
|
||
const satellites = next.satellites ?? [];
|
||
if (satellites.length === 0) return;
|
||
|
||
// nation id -> number of satellites it owns (stacks the bonus).
|
||
const counts = new Map<string, number>();
|
||
for (const s of satellites) {
|
||
counts.set(s.by, (counts.get(s.by) ?? 0) + 1);
|
||
}
|
||
|
||
const applyNation = (n: Nation, count: number): void => {
|
||
n.stats.stability = clampStat(n.stats.stability + count * SATELLITE_STABILITY_BONUS);
|
||
for (const key of n.territory) {
|
||
const rs = next.regionStats[key];
|
||
if (!rs) continue;
|
||
rs.stability = clampStat(rs.stability + count * SATELLITE_STABILITY_BONUS);
|
||
}
|
||
// Re-derive so the effect persists (the main recompute ran earlier this
|
||
// turn). A nation with no regionStats entry keeps its directly-mutated value.
|
||
if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) {
|
||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||
}
|
||
};
|
||
|
||
for (const [id, count] of counts) {
|
||
const n = next.nations[id];
|
||
if (!n) continue; // nation removed this turn: bonus dropped (satellite kept)
|
||
applyNation(n, count);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* (v0.45.0, #81) Apply the turn's space ops to `next`, mutating in place and
|
||
* never throwing. Runs in the `ABILITIES` loop AFTER the territory step and
|
||
* the derived-stats recompute, and BEFORE markers / nationOps, so the
|
||
* ownership it inspects is final for this turn.
|
||
*
|
||
* (1) DEORBIT first: remove satellites by id (absent id = no-op).
|
||
* (2) LAUNCH: for each launch op, resolve `by`, require `regionKey` to be a
|
||
* valid key owned by `by`, and append a fresh satellite stamped with
|
||
* `launchedTurn` / `launchedDate`.
|
||
* (3) PER-TURN BONUS: every active satellite grants its owner +1 stability
|
||
* (clamped, mirrored to its regions, re-derived) — one per satellite.
|
||
*
|
||
* Sole ownership is never touched; only `satellites` and stability change.
|
||
*/
|
||
export function applySpaceControl(next: GameState, ops: SpaceOps, ctx: AbilityCtx): void {
|
||
deorbitSatellites(next, ops?.deorbit ?? []);
|
||
launchSatellites(next, ops?.launch ?? [], ctx);
|
||
applySatelliteBonuses(next, ctx.geoOf);
|
||
}
|
||
|
||
export const space: Ability<'space'> = {
|
||
field: 'space',
|
||
default: () => ({ launch: [], deorbit: [] }),
|
||
validate: (raw) => validateSpaceOps(raw),
|
||
apply: (state, data, ctx) => applySpaceControl(state, data, ctx),
|
||
schemaLine: () =>
|
||
' "space": {"launch":[{"by":"<nation id or name>","regionKey":"<region key>","type":"<recon|comms|navigation|weather>"}],"deorbit":["<satellite id>"]},',
|
||
constraint: () =>
|
||
'- Space program ("space") is the orbital layer: a "launch" op ({"by":"<nation>","regionKey":"<key>","type":"<recon|comms|navigation|weather>"}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op (["<satellite id>"]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message\'s "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).',
|
||
isMeaningful: (data) => data.launch.length > 0 || data.deorbit.length > 0,
|
||
stateFields: ['satellites'],
|
||
stub: () => ({ launch: [], deorbit: [] }),
|
||
// Space is DURABLE (like sea / wars / industry): the user payload ALWAYS
|
||
// carries the current satellites (even when empty) so the model knows what
|
||
// is in orbit and can deorbit it.
|
||
payload: (ctx) => ({ satellites: ctx.state.satellites ?? [] }),
|
||
};
|