// 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 { 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; if (Array.isArray(o.launch)) { const seenLaunch = new Set(); for (const e of o.launch) { if (!e || typeof e !== 'object') continue; const p = e as Record; 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(); 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(); 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":"","regionKey":"","type":""}],"deorbit":[""]},', constraint: () => '- Space program ("space") is the orbital layer: a "launch" op ({"by":"","regionKey":"","type":""}) 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 ([""]) 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 ?? [] }), };