Files
History/server/abilities/space.ts
T
kamilkrzywda 283f9e0306
CI / build + unit + e2e (pull_request) Successful in 2m20s
feat: #80 + #81 alliances & space program — 13th & 14th abilities (v0.45.0)
#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.
2026-10-17 16:54:28 +00:00

216 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ?? [] }),
};