SpaceCities / engine /galaxy.js
Claude
Fix the three engine defects the 3D client reported (#92, #93, #94)
96c6d29 unverified
Raw History Blame
88.7 kB
/* ============================================================
The Odyssey galaxy β€” the open-world meta-layer over the per-planet sim. A
galaxy holds one engine game state per planet (each a normal createGameState);
one is "active" (rendered + controlled by the player) and ticks at full rate,
while the worlds you've left keep evolving in the background on a coarser
schedule (stepGalaxy). The player has a single, relocatable Command Center:
their capital seat travels with them via a Spaceport (jumpCapital), and a world
they leave stays a colony that keeps producing and sends home passive income.
This module owns everything meta: the world roster, universal credits, the
active planet, the per-frame advance (stepGalaxy), background-colony upkeep +
notifications (sweepColonies), a status snapshot for the starmap (galaxyStatus),
and the jump. The per-planet engine is untouched β€” a planet is just a normal
createGameState flagged `endless` (no victory, no clock β€” see engine/victory.js).
Determinism: the start world and every planet's map are derived from the galaxy
seed, and the background schedule is keyed to the integer `galaxy.tick`, so the
same seed replays the same galaxy β€” the same guarantee as the skirmish sim.
============================================================ */
"use strict";
import { createGameState, makeUnit, peekEntityId, restoreEntityId } from "./state.js";
import { mulberry32 } from "./rng.js";
import { updateFog } from "./fog.js";
import { tick } from "./sim.js";
import { createMarket } from "./market.js";
import { createDiplomacy, aiDevelopment, PEACE_THRESHOLD, ALLIED_THRESHOLD,
FACTION_ECHO_PENALTY, FACTION_ECHO_DURATION } from "./diplomacy.js";
import { UNITS, BUILDINGS, freightUsed, freightRoom } from "./entities.js";
import { hasColonyShip } from "./colony.js";
import { PLANET_ARCHETYPE, ODYSSEY_EXTRA_ARCHETYPE, archetypeFor } from "./aiArchetypes.js";
import { DIFFICULTY_OPTIONS } from "./aiDifficulty.js";
import { STRATEGIES } from "./aiStrategy.js";
import { runColonyPolicies } from "./colonyPolicy.js";
import { PLANETS, COM } from "../data.js";
// The worlds an Odyssey can settle: the skirmish nine PLUS the Odyssey-only extras
// (a research capital, an agri world) β€” appended AFTER the nine so the skirmish
// worlds keep their roster index, which keeps the background-tick schedule (keyed
// on worlds.indexOf) and every same-seed replay stable.
export const ODYSSEY_WORLDS = [...Object.keys(PLANET_ARCHETYPE), ...Object.keys(ODYSSEY_EXTRA_ARCHETYPE)];
// A stable per-planet seed derived from the galaxy seed + the world id, so every
// world generates its own deterministic map and two galaxies with the same seed
// are byte-identical.
function planetSeed(seed, planetId) {
let h = seed >>> 0;
for (let i = 0; i < planetId.length; i++) h = (Math.imul(h ^ planetId.charCodeAt(i), 0x01000193)) >>> 0;
return h >>> 0;
}
// LIVING GALAXY, BOUNDED. The galaxy is alive before you ever visit it β€” but not ALL of it at
// once: only this many of the OTHER worlds actually exist and simulate from turn one. The whole
// roster stays on the starmap and stays jumpable; an undrawn world is simply DORMANT until the
// player arrives, at which point jumpCapital instantiates it exactly as it always did for a
// never-visited destination. Bounding the live set keeps the per-frame background scheduler's
// work (and a save's size) proportional to a handful of worlds instead of the whole roster,
// while the galaxy still develops, spreads factions and races its Gate behind your back.
export const BACKGROUND_WORLDS = 3;
// WHICH worlds those are: a seeded, pseudo-random draw from the roster minus the start seat, so
// two runs from the same seed bring up the same neighbours (the determinism guarantee in this
// file's header) while two different seeds explore different pockets of the galaxy β€” the live set
// is part of what a seed means, not a fixed prefix of ODYSSEY_WORLDS.
//
// Drawn from its OWN distinctly-keyed stream (planetSeed with a ":backgroundWorlds" suffix, the
// same idiom neighbourAiProfile uses below) rather than createGalaxy's `pick`, so adding or
// changing this draw can never shift the start-world roll or any other seeded stream.
//
// Returned in fixed ROSTER order, not draw order: the background scheduler (stepGalaxy) keys on
// each world's roster index and every galaxy-wide scan walks `galaxy.worlds`, so keeping the
// creation order roster-ordered leaves those paths reading exactly as they did when every world
// was instantiated.
export function backgroundWorldIds(seed, startId, count = BACKGROUND_WORLDS) {
const pool = ODYSSEY_WORLDS.filter(id => id !== startId);
const n = Math.max(0, Math.min(count | 0, pool.length));
const pick = mulberry32(planetSeed(seed >>> 0, ":backgroundWorlds"));
// Partial Fisher-Yates: draw n distinct worlds without the retry loop a naive
// "roll until it's new" pick would need (and without its unbounded worst case).
for (let i = 0; i < n; i++) {
const j = i + Math.floor(pick() * (pool.length - i));
const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
}
const drawn = new Set(pool.slice(0, n));
return ODYSSEY_WORLDS.filter(id => drawn.has(id));
}
// VARIED NEIGHBOURS: every world but the player's start seat carries its OWN difficulty and AI
// Strategy (personality) β€” a distribution across the galaxy, some easier, some harder β€” instead
// of mirroring the player's own splash-screen pick everywhere. Pure function of the galaxy seed +
// planetId (a distinctly-keyed planetSeed, so this draws an independent stream from map
// generation's own rng), so the same galaxy seed always assigns the same profile to the same
// world: exploring is how you learn a neighbour's temperament, not a settings screen.
// aiApm/aiMicro are read off the SAME resolved difficulty entry, never left mismatched with a
// different world's dials (addPlanet threads all four through together).
export function neighbourAiProfile(seed, planetId) {
const pick = mulberry32(planetSeed(seed, planetId + ":neighbourProfile"));
const diffOpt = DIFFICULTY_OPTIONS[Math.floor(pick() * DIFFICULTY_OPTIONS.length)];
const strategyKeys = Object.keys(STRATEGIES);
const aiStrategy = strategyKeys[Math.floor(pick() * strategyKeys.length)];
return { difficulty: diffOpt.mult, aiApm: diffOpt.aiApm, aiMicro: diffOpt.aiMicro, aiStrategy };
}
// Create an Odyssey galaxy. Phase 1: a single active planet (the player's
// randomly-chosen β€” or explicitly PICKED β€” starting world) plus the meta-fields
// (credits, activeId, the world roster) the later phases grow into.
export function createGalaxy({ seed = 1, difficulty = "medium", sizeMult = 1,
resourceMult = 1, playerFaction = "frontier", aiApm, aiMicro, aiStrategy, startId: startIdOpt, popCap = null } = {}) {
seed = seed >>> 0;
const pick = mulberry32(seed);
// Always draw, even when the caller supplies an explicit startId: consuming this draw keeps
// every OTHER seed-derived RNG stream byte-identical whether the player picked a world or left
// it to chance (CONTRIBUTING.md's determinism guarantee) β€” skipping the draw on the "explicit
// pick" path would silently shift whatever a later change might draw from this same `pick`.
const drawnId = ODYSSEY_WORLDS[Math.floor(pick() * ODYSSEY_WORLDS.length)];
const startId = (startIdOpt != null && ODYSSEY_WORLDS.includes(startIdOpt)) ? startIdOpt : drawnId;
const galaxy = {
seed,
credits: 500, // universal credits β€” galaxy-wide, transportable; fund jumps + trade
activeId: startId, // the world the player is currently on
worlds: ODYSSEY_WORLDS.slice(),
planets: new Map(), // planetId -> engine game state
// `startId` records which world was actually landed on (the player's pick, or the seed's own
// draw) β€” informational, not read back to reconstruct anything (activeId already IS that
// world, and persists on its own); rides along for free since `settings` is saved whole.
settings: { difficulty, sizeMult, resourceMult, playerFaction, aiApm, aiMicro, aiStrategy, startId, popCap },
tick: 0, // integer galaxy-tick counter (drives the background-world schedule)
time: 0, // galaxy-wide sim clock (seconds) β€” monotonic across jumps; keys the relief cooldown
entitySeq: 0, // fresh-id counter for entities relocated across worlds by a jump
colonyNotes: new Map(), // per-planet UI notification bookkeeping (galaxy-side, not sim state)
pacified: new Set(), // worlds where you've razed the neighbour's Command Center (a conquest milestone)
pacifyNotes: [], // freshly-pacified world ids awaiting a UI toast + firework (transient, drained by boot.js)
reached: new Set(), // progress milestones already celebrated (see checkGalaxyProgress) β€” persisted so a reload doesn't re-fire them
milestones: [], // freshly-reached milestones awaiting a UI firework (transient, drained by boot.js)
wonBy: null, // legacy: no Odyssey win any more (play-forever) β€” kept null for save/skirmish compat
discovered: new Set([startId]), // LIVING GALAXY: worlds the player has actually reached (starmap "explored" + free return-jump). Every world SIMULATES from the start, but the player only SEES a world once they've been there.
claims: new Map(), // faction spread: worldId β†’ controlling faction (checkExpansion), shown on the starmap
expansionNotes: [], // freshly-claimed/expanded worlds awaiting a UI toast (transient, drained by boot.js)
lanes: [], // Freight Lanes (runLanes below): standing shipping routes between held worlds
laneSeq: 0, // fresh lane-id counter (createLane)
};
addPlanet(galaxy, startId);
// LIVING GALAXY: a seeded handful of the other worlds (backgroundWorldIds, above) already exist and
// simulate in the background from turn one β€” each with its own AI faction founding a base and
// developing (engine/ai.js aiIndustry), its economy growing and its diplomacy drifting β€” so the
// galaxy is alive before you ever visit, and (checkExpansion) factions spread across it over time.
// Added `unsettled` (no player presence until you jump in). The rest of the roster stays DORMANT
// and is built in on arrival (jumpCapital), so every world remains a destination. The BG scheduler
// (stepGalaxy) spreads the live ones' ticks round-robin. Deterministic: the draw and each world's
// map both come from the galaxy seed, so two same-seed galaxies are byte-identical.
for (const id of backgroundWorldIds(seed, startId)) addPlanet(galaxy, id, { unsettled: true });
return galaxy;
}
// Units staged within this radius of a Spaceport ride along with the capital on
// a jump β€” you assemble your expedition at the pad, then launch.
export const JUMP_LOAD_RADIUS = 150;
// Credits a jump costs (fuel). Funded by trading at the market, so exploration
// draws on your economy rather than being free.
export const JUMP_COST = 400;
// A held background colony sends home this many credits per second per surviving
// income-earning player building β€” passive income, so the worlds you leave keep working
// for you (and a razed colony, down to no buildings, quietly stops paying).
export const COLONY_INCOME_PER_BUILDING = 0.3;
// …but only up to this many buildings per world. Without a cap, spamming the cheapest
// building (a 75-ore Habitat, a turret) on a pacified world β€” where the razed neighbour
// can never strike back β€” was an unbounded credit annuity that made the counter a
// meaningless up-only number. The cap bounds a colony's yield to a real economy's worth.
export const COLONY_INCOME_CAP = 6;
// DOMINATION WITH TEETH, optional occupation dividend (docs/improvement-proposals.md 663): a
// pacified world pays a small flat credits/sec just for being held down β€” no player building
// required, since the point is that razing the neighbour's capital and keeping it razed is worth
// something on its own, distinct from (and additive with) whatever a colony you've also founded
// there separately earns above. Same universal-credits currency as COLONY_INCOME_PER_BUILDING and
// a small fraction of what a single well-built colony can pay at its cap (COLONY_INCOME_PER_BUILDING
// * COLONY_INCOME_CAP = 1.8/s), so conquest is a genuine but modest reward, not a better economy
// than actually building one.
export const PACIFIED_INCOME = 0.1; // credits/sec per pacified world (~6/min)
const playerBuildingCount = state => {
let n = 0;
for (const b of state.buildings.values()) if (b.owner === "player") n++;
return n;
};
// Buildings that count toward passive income: everything except the pure-defensive
// turret (a turret wall isn't an economy), capped per world (see COLONY_INCOME_CAP).
const incomeBuildingCount = state => {
let n = 0;
for (const b of state.buildings.values()) if (b.owner === "player" && b.type !== "turret") n++;
return Math.min(COLONY_INCOME_CAP, n);
};
// Build (or rebuild) a planet's engine state into the galaxy. Reuses the exact
// skirmish scaffold β€” economy, both players' bases, fog β€” but flagged `endless`
// so it never resolves by conquest or clock (see engine/victory.js).
//
// `unsettled` strips the auto-seeded player presence: a jump DESTINATION you
// haven't settled yet has only its neighbour β€” your capital + forces arrive via
// the jump, not from map generation.
//
// AI dials: the player's OWN splash-screen difficulty/strategy pick (galaxy.settings) applies
// only to the start seat (planetId === galaxy.activeId, true for every world at the point
// createGalaxy calls this). Every other world resolves its own varied profile instead
// (neighbourAiProfile, above) β€” a distribution across the galaxy, not one setting everywhere.
export function addPlanet(galaxy, planetId, { unsettled = false } = {}) {
const state = buildPlanetState(galaxy, planetId, unsettled);
galaxy.planets.set(planetId, state);
bumpEntityCounterPastGalaxy(galaxy); // keep future live-built ids galaxy-unique (see below)
return state;
}
// A DORMANT world's state, built to LOOK at without waking it. The landing picker
// (landingPicker.js) has to draw the destination's map before the player commits to the jump, and
// only a seeded handful of worlds are alive from turn one (BACKGROUND_WORLDS) β€” so merely opening
// that modal, or backing out of it, must not add a world to the live set. Built from the same
// (galaxy seed, planetId) inputs addPlanet uses and never ticked, so the state the jump really
// creates on arrival is identical to the one previewed. A world that's already up IS its own
// preview and is returned as-is.
export function previewPlanet(galaxy, planetId) {
const live = galaxy.planets.get(planetId);
if (live) return live;
const state = buildPlanetState(galaxy, planetId, true);
// Even a throwaway world moves the GLOBAL entity-id counter (createGameState re-seeds it from 1
// per world β€” see bumpEntityCounterPastGalaxy below), so the same bump the registering path does
// is mandatory here too: without it the next thing built anywhere could reuse a live id.
bumpEntityCounterPastGalaxy(galaxy);
return state;
}
// The shared core of both: a planet's engine state, built but not registered.
function buildPlanetState(galaxy, planetId, unsettled) {
const s = galaxy.settings;
const seed = planetSeed(galaxy.seed, planetId);
// A DORMANT world (one the background draw didn't bring up) can be CLAIMED by a spreading faction
// before it ever exists β€” checkExpansion's sweep walks the whole roster, and the starmap flies the
// claim on it. So a world built in later takes the claimed colours over its own archetype's, exactly
// as checkExpansion flips an already-instantiated world's AI faction on the spot: the flag the
// player has been looking at is the one they meet on landing.
const claimed = galaxy.claims && galaxy.claims.get(planetId);
const aiFaction = claimed || archetypeFor(planetId).faction || "neutral";
const profile = planetId === galaxy.activeId
? { difficulty: s.difficulty, aiApm: s.aiApm, aiMicro: s.aiMicro, aiStrategy: s.aiStrategy }
: neighbourAiProfile(galaxy.seed, planetId);
const state = createGameState({
planetId, seed, rng: mulberry32(seed),
aiApm: profile.aiApm, aiMicro: profile.aiMicro, aiStrategy: profile.aiStrategy, difficulty: profile.difficulty,
sizeMult: s.sizeMult, resourceMult: s.resourceMult, popCap: s.popCap,
playerFaction: s.playerFaction, aiFaction, endless: true,
});
if (unsettled) {
for (const [id, u] of [...state.units]) if (u.owner === "player") state.units.delete(id);
for (const [id, b] of [...state.buildings]) if (b.owner === "player") state.buildings.delete(id);
state.background = true; // not the active seat until you land here
updateFog(state, state.fog, "player");
}
state.market = createMarket(state); // every world has its own price book
state.diplomacy = createDiplomacy(); // and its own neighbour's stance toward you
state.inGalaxy = true; // part of a galaxy β†’ the per-world defeat check is off (engine/victory.js);
// the galaxy never loses (checkGalaxyRescue), it only ends by surrender
return state;
}
// createGameState (engine/state.js) resets the GLOBAL entity-id counter to 1 for each world's
// deterministic seeding β€” which, once a galaxy holds more than one world, would leave the counter
// BELOW ids already live on other worlds. The next thing the player then built or produced could
// reuse an id and silently overwrite an existing entity (e.g. return to a built-up world after
// visiting a new one and lay down a Spaceport β†’ it clobbers a building that shared the id). So
// after each world is (re)built in, bump the counter past every b/u id anywhere in the galaxy, so
// live mints stay unique galaxy-wide. Deterministic β€” reads only entity ids (no clock/RNG); the
// seeded ids themselves are untouched, so per-world determinism is preserved.
function idNum(id) {
const n = parseInt(String(id).replace(/^\D+/, ""), 10); // "b12" | "u7" | "g3" β†’ 12 | 7 | 3
return Number.isFinite(n) ? n : 0;
}
function bumpEntityCounterPastGalaxy(galaxy) {
let max = peekEntityId() - 1; // whatever this world's own seeding reached
for (const state of galaxy.planets.values()) {
for (const id of state.units.keys()) max = Math.max(max, idNum(id));
for (const id of state.buildings.keys()) max = Math.max(max, idNum(id));
}
restoreEntityId(max + 1);
}
// Run the background colonies each tick: bank their passive income, watch for
// trouble, and return notifications for the UI. Also the single place their sim
// events are drained β€” a colony isn't rendered or heard, so nothing else consumes
// them (left alone they would grow without bound). Reports a colony coming under
// attack (a player asset destroyed there) and a colony being lost (its last
// player building razed), each at most once per state transition. The "already
// notified" flags live on the galaxy (colonyNotes), not on the deterministic
// engine state β€” they're transient UI bookkeeping, re-derived harmlessly on load.
export function sweepColonies(galaxy, dt = 0) {
const out = [];
for (const [id, state] of galaxy.planets) {
if (!state.background) continue;
const buildings = playerBuildingCount(state);
galaxy.credits += incomeBuildingCount(state) * COLONY_INCOME_PER_BUILDING * dt; // capped, turret-excluded passive income
if (galaxy.pacified && galaxy.pacified.has(id)) galaxy.credits += PACIFIED_INCOME * dt; // occupation dividend, additive
const rec = galaxy.colonyNotes.get(id) || { hadColony: false, colonyLost: false };
// A standing colony resets the lost latch, so retaking and rebuilding a world re-arms
// its alerts β€” without this, a world lost once was muted forever (a second razing never
// re-announced, and the rebuilt colony's under-attack pings stayed suppressed).
if (buildings > 0) { rec.hadColony = true; rec.colonyLost = false; }
if (rec.hadColony && buildings === 0 && !rec.colonyLost) {
rec.colonyLost = true;
out.push({ type: "lost", planetId: id });
} else if (rec.hadColony && !rec.colonyLost) {
// Only worlds the player actually colonised raise hostile/attacked alerts β€” the living galaxy
// now background-simulates every world (its AI can drift to war on its own), but a neighbour
// turning on a world you've never set foot on isn't your problem and mustn't ping you.
// A background world's diplomacy keeps drifting (diplomacy.js CREEP_RATE), so a
// neighbour eventually declares war β€” but its neighbourHostile event has no other
// consumer here and used to be silently drained, so the FIRST warning was the colony
// already dying. Surface the declaration (once β€” diplomacy latches warAnnounced), else
// the ongoing raid (fresh player losses this tick).
if (state.events.some(e => e.type === "neighbourHostile")) out.push({ type: "hostile", planetId: id });
else if (state.events.some(e => e.type === "entityKilled" && e.owner === "player")) out.push({ type: "attacked", planetId: id });
}
galaxy.colonyNotes.set(id, rec);
state.events.length = 0; // drain: a background colony's events have no other consumer
}
return out;
}
// The game state the player is currently on β€” what boot.js renders and drives.
export function activeState(galaxy) {
return galaxy.planets.get(galaxy.activeId);
}
// Background worlds tick once every BG_STEP galaxy-ticks, each time by BG_STEPΓ— the
// step, so a colony advances the same amount of sim time as the active world over
// any span β€” just in coarser, cheaper increments.
export const BG_STEP = 4;
// id -> roster-index lookup for `galaxy.worlds`, cached by the ARRAY's own identity rather than
// on the galaxy object: `worlds` is built in two places (createGalaxy above, and
// deserializeGalaxy in engine/persist.js), and both set it exactly once and never mutate it in
// place afterwards (append-only roster growth on load rebuilds the array β€” see persist.js β€”
// it doesn't push/splice the live `galaxy.worlds` reference), so a WeakMap keyed on that
// reference is a safe, self-invalidating O(1) replacement for the old per-frame
// `galaxy.worlds.indexOf(id)` scan (stepGalaxy runs this once per background world every
// frame, and per its header comment the whole scheduler is meant to stay well under 1ms/frame).
const worldIndexCache = new WeakMap();
function worldIndexMap(worlds) {
let idx = worldIndexCache.get(worlds);
if (!idx) { idx = new Map(worlds.map((id, i) => [id, i])); worldIndexCache.set(worlds, idx); }
return idx;
}
// Advance the whole galaxy by one frame. The active world ticks every frame at
// full cadence (it's rendered and controlled). Every background colony ticks on a
// coarser fixed step, spread round-robin across BG_STEP frames by its fixed roster
// index, so per-frame background work is ~ceil(N/BG_STEP) worlds instead of N.
// Deterministic by construction: the schedule is pure integer arithmetic on the
// galaxy tick and the world's roster position (no wall-clock, no Map-order
// dependence), and each background tick uses the exact constant dtBg so total sim
// time is conserved regardless of cadence.
export function stepGalaxy(galaxy, dt) {
const t = (galaxy.tick = (galaxy.tick | 0) + 1);
galaxy.time = (galaxy.time || 0) + dt; // galaxy-wide clock (deterministic: dt is the fixed step)
tick(activeState(galaxy), dt); // active world: full rate
const dtBg = dt * BG_STEP;
const worldIndex = worldIndexMap(galaxy.worlds);
for (const [id, state] of galaxy.planets) {
if (id === galaxy.activeId || !state.background) continue;
if (t % BG_STEP === worldIndex.get(id) % BG_STEP) tick(state, dtBg);
}
// Freight Lanes: a standing shipping drip, on its own integer-tick schedule (LANE_PERIOD) β€”
// independent of the ~1/sec scan below (lanes move real cargo, so they're worth their own,
// coarser cadence rather than piggybacking on a scan meant for cheap galaxy-wide bookkeeping).
if (t % LANE_PERIOD === 0) runLanes(galaxy);
// These galaxy-wide scans (conquest progress, milestones, no-foothold relief) all change on a
// minutes timescale, so running them every frame (20 Hz) is wasted work that grows with the
// colony count. Throttle to ~1/sec on the same deterministic integer schedule the BG round-
// robin uses β€” a milestone/pacification/rescue firing up to a second later is imperceptible,
// and RELIEF_COOLDOWN (20 s) dwarfs it. Runs on tick 1 too, so a freshly-stepped galaxy is
// checked immediately. Pure integer arithmetic on galaxy.tick β€” no wall-clock, deterministic.
if (t === 1 || t % PROGRESS_CHECK_EVERY === 0) {
checkDomination(galaxy); // conquest progress: pacified worlds (per-world toast) + a milestone at the target
checkExpansion(galaxy); // faction spread: developed AI worlds claim + colonise across the starmap
updateFactionWarmth(galaxy); // faction memory (allied direction): refresh each world's Allied-faction-mate count
checkGalaxyProgress(galaxy); // milestones: colonies founded, the Antimatter Gate coming online β€” fireworks, not wins
checkRivalGate(galaxy); // the rival Gate: track the galaxy's own charging threat, ascend it on completion
checkGalaxyRescue(galaxy); // NEVER auto-defeat: a total wipeout sends a relief colony ship so life goes on
runColonyPolicies(galaxy); // colony standing orders (engine/colonyPolicy.js): auto-sell + sustain workers, background worlds only
}
}
const PROGRESS_CHECK_EVERY = 20; // galaxy-wide scans run ~once per second (20 Hz sim), not every frame
// The Odyssey NEVER ends in defeat β€” as long as you haven't surrendered, life goes on
// (surrenderGalaxy is the only terminal state). So there's no galaxy-wide loss; instead, if you
// ever hold NO foothold anywhere (every Command Center razed and no colony ship left), a RELIEF
// colony ship is dispatched to your active world's landing zone so you can re-found and fight on.
// A short cooldown bounds the churn if relief keeps getting farmed, and its arrival is flagged for
// a UI toast. Once you hold the ship you have a foothold again, so it won't re-send. The per-world
// checkEndlessLoss is suppressed for galaxy states (state.inGalaxy, engine/victory.js), so this is
// the sole authority. The cooldown is keyed on the GALAXY-WIDE clock (galaxy.time), not any one
// world's local time β€” a jump swaps the active world, and each world's clock advances on its own,
// so comparing lastReliefTime (set on world A) to world B's time could read as "cooldown elapsed"
// (B younger than A β‡’ negative delta) or "never elapses", either of which breaks the anti-farm
// bound and could dead-end the no-defeat guarantee. galaxy.time is monotonic across the whole run.
// Pure + deterministic (galaxy.time accumulates the fixed dt in stepGalaxy).
export const RELIEF_COOLDOWN = 20; // sim seconds between relief drops (anti-farm)
export function checkGalaxyRescue(galaxy) {
const active = activeState(galaxy);
if (active.over) return; // a surrender already ended it
for (const state of galaxy.planets.values()) {
for (const b of state.buildings.values())
if (b.owner === "player" && b.type === "command") return; // a base somewhere β†’ still going
if (hasColonyShip(state, "player")) return; // …or a colony ship to re-found from
}
const now = galaxy.time || 0;
if (now - (galaxy.lastReliefTime ?? -Infinity) < RELIEF_COOLDOWN) return; // still on cooldown
galaxy.lastReliefTime = now;
const lz = active.map.bases.player;
const ship = makeUnit("colonyship", "player", lz.x, lz.y);
ship.id = "g" + (galaxy.entitySeq = (galaxy.entitySeq || 0) + 1); // galaxy id scheme (as in jumpCapital)
active.units.set(ship.id, ship);
galaxy.reliefNote = true; // drained by boot.js for a toast
}
// The one terminal state: the player voluntarily gives up. Ends the run on the active seat (the
// boot.js over-poll then shows the game-over screen with the surrender copy). A wipeout alone
// never triggers this β€” only an explicit surrender does.
export function surrenderGalaxy(galaxy) {
const active = activeState(galaxy);
if (active.over) return;
active.over = true; active.winner = "ai";
galaxy.surrendered = true;
}
// Record a one-time progress milestone β€” a firework, drained UI-side by boot.js. Idempotent
// per id, and `reached` persists (engine/persist.js) so a reload never replays a milestone
// you've already celebrated.
// The complete milestone vocabulary β€” the ONLY ids reachMilestone ever mints. Exported so the load
// path can filter a saved `reached` set against it (engine/persist.js): an unfiltered set let a
// hand-edited save inject junk, and β€” more importantly β€” filtering against the WORLD roster instead
// would silently drop every real milestone and replay its fireworks on the next load.
export const MILESTONE_IDS = ["capital", "gate", "domination", "domination:all", "rival-gate"];
/** True for any id reachMilestone can produce, including the per-world `world:N` family. */
export function isMilestoneId(id) {
return typeof id === "string" && (MILESTONE_IDS.includes(id) || /^world:\d+$/.test(id));
}
function reachMilestone(galaxy, id) {
if (galaxy.reached.has(id)) return;
galaxy.reached.add(id);
galaxy.milestones.push(id);
}
// Progress milestones for the play-forever sandbox β€” the fireworks that mark how far you've
// come, in place of a victory that would end the run. Swept galaxy-wide each tick, each fired
// once (reachMilestone): founding your first base and each further WORLD you settle
// ("world:N"), fortifying your first Capital ("capital"), and bringing an Antimatter Gate
// online anywhere ("gate" β€” the former economic victory, now a triumph you keep playing past).
// Conquest milestones live in checkDomination. Pure β€” reads only entity state; the firework
// itself is fired UI-side (boot.js), keeping the engine DOM-free.
export function checkGalaxyProgress(galaxy) {
let settledWorlds = 0, hasCapital = false, gateOnline = false;
for (const state of galaxy.planets.values()) {
let heldHere = false;
for (const b of state.buildings.values()) {
if (b.owner !== "player") continue;
if (b.type === "command" && !b.constructing) { heldHere = true; if (b.capital) hasCapital = true; }
if (BUILDINGS[b.type]?.wonder && (b.charge || 0) >= 1) gateOnline = true;
}
if (heldHere) settledWorlds++;
}
for (let n = 1; n <= settledWorlds; n++) reachMilestone(galaxy, "world:" + n);
if (hasCapital) reachMilestone(galaxy, "capital");
if (gateOnline) reachMilestone(galaxy, "gate");
}
// Worlds to pacify (raze the neighbour's Command Center on) for the grand CONQUEST
// milestone β€” the military, multi-world firework. No longer a win: the galaxy keeps
// running past it (play-forever).
export const DOMINATION_TARGET = 4;
// The AI's foothold on a world β€” a Command Center OR an undeployed colony ship. The
// colony-ship clause is what stops checkDomination false-pacifying every world at
// tick 0 (both sides now START with a CC-less colony ship), and keeps "pacified"
// meaning "you actually drove them off" β€” a neighbour reduced to a lone ship can still
// re-found, so it isn't conquered yet. hasColonyShip is false in a skirmish.
const hasAiCommand = state => {
for (const b of state.buildings.values()) if (b.owner === "ai" && b.type === "command" && !b.constructing) return true;
return hasColonyShip(state, "ai");
};
// The faction a world answers to for cross-world faction-memory purposes (the grievance echo
// below, and updateFactionWarmth's Allied lift): its DYNAMIC controlling faction (checkExpansion's
// claims, once spread) when it has one, else its own neighbour's faction (state.players.ai.faction)
// β€” the same claim-first, AI-faction-fallback resolution checkExpansion's own self-claim step
// effectively establishes. Null for a state with no AI player at all.
function worldFaction(galaxy, id, state) {
const claimed = galaxy.claims && galaxy.claims.get(id);
if (claimed) return claimed;
return (state && state.players.ai && state.players.ai.faction) || null;
}
// Conquest progress. A world is "pacified" the moment its neighbour has no standing
// Command Center β€” you razed it (only two sides fight, and every world is seeded with an
// AI capital). Pacification is STICKY (recorded on the galaxy, so a neighbour rebuilding
// can't un-pacify it); each freshly-pacified world is queued for a UI toast + firework
// (pacifyNotes), and reaching DOMINATION_TARGET fires the grand "domination" milestone β€”
// a firework, NOT a win, so the sandbox plays on. Deterministic β€” reads only entity state.
//
// DOMINATION WITH TEETH (docs/improvement-proposals.md 657-665): also stamp the diplomacy-side
// floor flag (state.diplomacy.pacified) on the razed world itself β€” checkDomination already holds
// that state in this loop. engine/diplomacy.js's updateDiplomacy reads the flag to floor the
// world's drift target at Neutral permanently, so a conquered neighbour rebuilds and defends
// itself but never re-initiates.
//
// FACTION MEMORY (docs/improvement-proposals.md 519-527), tuned as ONE system with the floor
// above: once every fresh pacification THIS TICK is recorded (so two faction-mates pacified in
// the same tick correctly exclude each other below, regardless of which one Map iteration visits
// first β€” see the second loop), echo a bounded, ONE-TIME stance penalty onto each pacified world's
// remaining UNPACIFIED faction-mates (matched by claim or AI faction β€” worldFaction, above), plus a
// starmap toast queued the same way checkExpansion already queues its own (expansionNotes). An
// already-pacified faction-mate is excluded β€” its own floor (this same function, an earlier tick)
// already holds it at Neutral-or-better, so a further hit would only fight that floor for no
// visible effect; a domination spree instead snowballs resistance across whichever of the
// faction's worlds AREN'T yet conquered.
export function checkDomination(galaxy) {
const active = activeState(galaxy);
if (active.over) return;
const freshlyPacified = [];
for (const [id, state] of galaxy.planets) {
if (galaxy.pacified.has(id) || hasAiCommand(state)) continue;
galaxy.pacified.add(id);
galaxy.pacifyNotes.push(id);
state.diplomacy.pacified = true;
freshlyPacified.push({ id, state });
}
for (const { id, state } of freshlyPacified) {
const faction = worldFaction(galaxy, id, state);
if (!faction || faction === "neutral") continue;
for (const [mateId, mateState] of galaxy.planets) {
if (mateId === id || galaxy.pacified.has(mateId)) continue;
if (worldFaction(galaxy, mateId, mateState) !== faction) continue;
const dip = mateState.diplomacy;
if (!dip) continue;
dip.stance = Math.max(-1, Math.min(1, dip.stance - FACTION_ECHO_PENALTY));
dip.factionEchoUntil = mateState.time + FACTION_ECHO_DURATION;
}
galaxy.expansionNotes.push({ type: "factionEcho", planetId: id, faction });
}
if (galaxy.pacified.size >= DOMINATION_TARGET) reachMilestone(galaxy, "domination");
// The maximal achievement in a play-forever sandbox β€” pacifying EVERY world β€” gets its
// own grander milestone, so the conquest-minded player who pushes hours past the 4-world
// target isn't met with silence (and a per-conquest toast reading "Conquered 11/4").
if (galaxy.pacified.size >= galaxy.worlds.length) reachMilestone(galaxy, "domination:all");
}
// FACTION SPREAD across the galaxy. The living galaxy fills up with developing AI factions
// (engine/ai.js aiIndustry); this is how their influence spreads between planets over a long game:
// β€’ SELF-CLAIM β€” a world whose AI has developed past CLAIM_DEV flies its own faction's flag.
// β€’ EXPAND β€” the single most-developed claimed world (past EXPAND_DEV) colonises the NEAREST still-
// unclaimed world, claiming it and flipping that world's AI to its colours β€” so a thriving faction
// absorbs the fringe and its territory grows across the starmap.
// One cross-planet claim per scan keeps the spread gradual; it's paced by how fast worlds develop
// (minutes), and converges once every world is claimed. Deterministic: development is an entity count,
// distance is fixed planet-x, order is the fixed roster, ties break by world id β€” no clock, no RNG.
// Reads/writes only galaxy.claims (persisted) + the AI's faction (persisted), so it's save/load-safe.
export const CLAIM_DEV = 3; // industrial development at which a world's AI faction claims its homeworld
export const EXPAND_DEV = 6; // ...and at which a thriving world reaches out to colonise a neighbour
export function checkExpansion(galaxy) {
const claims = galaxy.claims || (galaxy.claims = new Map());
const notes = galaxy.expansionNotes || (galaxy.expansionNotes = []);
// SELF-CLAIM: every developed homeworld with a standing base flies its faction's flag (roster order).
for (const id of galaxy.worlds) {
if (claims.has(id)) continue;
const s = galaxy.planets.get(id);
if (!s || !hasAiCommand(s) || aiDevelopment(s) < CLAIM_DEV) continue;
const faction = s.players.ai ? s.players.ai.faction : "neutral";
claims.set(id, faction);
notes.push({ type: "claim", planetId: id, faction });
}
// EXPAND: the single most-developed claimed world past EXPAND_DEV (tie by id) colonises the nearest
// still-unclaimed world (tie by id). One per scan β†’ gradual spread.
let exp = null, expDev = EXPAND_DEV - 1e-9;
for (const id of galaxy.worlds) {
if (!claims.has(id)) continue;
const s = galaxy.planets.get(id);
if (!s) continue;
const dev = aiDevelopment(s);
if (dev > expDev || (dev === expDev && exp !== null && id < exp)) { expDev = dev; exp = id; }
}
if (exp === null) return;
let best = null, bestD = Infinity;
for (const other of galaxy.worlds) {
if (claims.has(other)) continue;
// Never colonise the world the player is on, or one they hold a base on. Expansion flips the
// target world's AI faction (below), and faction feeds LIVE combat/economy multipliers
// (factions.js β†’ sideMod, read on every hit and haul) β€” so flipping a world the player is
// actively fighting on would silently change the enemy's damage/sight/speed/gather mid-battle,
// with only a starmap toast to show for it. Spread only reaches the unclaimed, unheld fringe.
if (other === galaxy.activeId || playerFoothold(galaxy.planets.get(other))) continue;
const d = Math.abs(planetX(other) - planetX(exp));
if (d < bestD || (d === bestD && best !== null && other < best)) { bestD = d; best = other; }
}
if (best === null) return;
const faction = claims.get(exp);
claims.set(best, faction);
const bs = galaxy.planets.get(best);
if (bs && bs.players.ai) bs.players.ai.faction = faction; // the colonised world flies the expander's colours
notes.push({ type: "expand", from: exp, planetId: best, faction });
}
// FACTION MEMORY, allied direction (docs/improvement-proposals.md 519-527) β€” the mirror of
// checkDomination's grievance echo above: holding an Allied stance on one world lifts the drift
// TARGET of its faction-mates a little (engine/diplomacy.js updateDiplomacy), so befriending a
// bloc pays across its whole territory. Unlike the grievance echo this isn't a one-time event β€”
// it's a continuously-held CONDITION β€” so it's recomputed here on the same throttled ~1/sec scan
// stepGalaxy already runs checkDomination/checkExpansion on, rather than latched by an event: a
// world that drifts back down out of Allied stops granting the lift again within about a second.
//
// Stores a RAW COUNT per world (dip.factionWarmth: how many of ITS OTHER faction-mates are
// currently Allied), mirroring aiDevelopment's own raw-count shape β€” updateDiplomacy applies the
// per-unit weight and cap at the point of use, exactly like aiDevelopment's own
// DEV_SOFT_PER/DEV_SOFT_CAP. Pure (reads only persisted galaxy/diplomacy state) and deterministic
// (fixed roster order, no RNG, no wall clock).
export function updateFactionWarmth(galaxy) {
const alliedByFaction = new Map(); // faction -> how many of its worlds are currently Allied
for (const id of galaxy.worlds) {
const state = galaxy.planets.get(id);
const dip = state && state.diplomacy;
if (!dip || dip.stance < ALLIED_THRESHOLD) continue;
const faction = worldFaction(galaxy, id, state);
if (!faction || faction === "neutral") continue;
alliedByFaction.set(faction, (alliedByFaction.get(faction) || 0) + 1);
}
for (const id of galaxy.worlds) {
const state = galaxy.planets.get(id);
const dip = state && state.diplomacy;
if (!dip) continue;
const faction = worldFaction(galaxy, id, state);
let count = (faction && alliedByFaction.get(faction)) || 0;
if (dip.stance >= ALLIED_THRESHOLD) count--; // a world's own Allied stance doesn't lift itself
dip.factionWarmth = Math.max(0, count);
}
}
/* ---------- THE RIVAL GATE (docs/improvement-proposals.md "the galaxy's strongest faction races
its own wonder" + "a fully-teched neighbour races its own Antimatter Gate", merged per the
Phase 7 plan β€” two independently-written proposals describing the same feature, reconciled
into one mechanism here).
The TRIGGER (whether a world raises and charges its own Gate at all) lives entirely in
engine/aiIndustry.js's rivalGateEligible/RIVAL_GATE_BUFFER β€” every qualifying world builds its
own Gate independently, self-contained, on its own think cycle, exactly like every other
aiIndustry.js building decision. This module's job is narrower: pick which ONE currently-
charging world is surfaced to the player as THE Rival Gate (checkExpansion's own most-
developed, tie-by-id idiom, just above β€” deterministic, no clock/RNG), track its live charge
for the starmap, and apply the 'ascension' consequence to EVERY world whose Gate completes β€”
not only the tracked one, so two simultaneously-eligible worlds (a rare but real possibility on
a Hard-heavy galaxy) can't let a second Gate complete unnoticed just because this scan happened
to be watching a different one.
THE ASCENSION CONSEQUENCE deliberately COMBINES both twins' proposals rather than picking one:
the claimed-neighbours burst (Defense twin) AND the hardEdge upgrade + a permanent stance
ceiling (AI twin) are not mutually exclusive, and the trigger is already double-gated (the
Strategic tier banked AND Hard/wantsDeepIndustry) β€” a genuinely rare, hard-earned event, so
escalating hard on it reads as "the galaxy changed under you" (the Defense twin's own framing)
rather than as pile-on. Never a defeat: state.over is never touched by any of this. */
// The AI twin's proposal literally says "a permanent stance floor" for the ascension; taken
// literally that would HELP the player (the same shape as diplomacy.js's own APPEASE_FLOOR truce
// line), which contradicts its own framing as a "penalty toward the player" β€” so this reads it as
// a permanent CEILING instead: the ascended world's stance can never rise back past Wary, no
// matter how the scarcity/development pull on it evolves. Reapplied every scan (below), not a
// one-time snap β€” updateDiplomacy's own drift would otherwise claw it back up, and the AI twin's
// own "Where" note explicitly keeps this out of engine/diplomacy.js ("needs no change").
//
// EXCEPT once the player has personally pacified that same world (Domination with teeth,
// checkDomination/dip.pacified below): conquest is the more direct, more recent player action, so
// it wins over a stale ascension from before the capital fell. Without this guard the ceiling and
// the pacified floor fight forever β€” the ceiling reasserts every scan and always wins the raw
// stance value between updateDiplomacy's slower drift ticks, so razing an ascended neighbour's
// Command Center would have no visible effect on its stance, which reads as broken in play.
export const RIVAL_ASCENSION_STANCE_CEILING = PEACE_THRESHOLD;
// The currently-charging AI-owned wonder building on `state`, or null β€” the same predicate
// aiIndustry.js's own `hasGate` check uses, just read from the outside.
function aiWonderOn(state) {
for (const b of state.buildings.values())
if (b.owner === "ai" && BUILDINGS[b.type]?.wonder) return b;
return null;
}
// Apply the ascension consequence to `worldId` exactly once. Combines both twins' proposals (see
// the header comment above): hardEdge + a permanent stance ceiling, AND an all-at-once claims
// burst β€” every still-unclaimed world the player hasn't founded/settled on and isn't currently
// standing on (the exact same safety exclusion checkExpansion's own EXPAND step already applies,
// for the exact same reason: flipping a world's faction out from under live combat/economy
// multipliers). Deliberately ALL AT ONCE rather than checkExpansion's own one-per-scan trickle β€”
// an ascension is a single dramatic event, not a gradual spread.
function applyRivalAscension(galaxy, worldId, state) {
if (state.players.ai) state.players.ai.upgrades.hardEdge = true;
(galaxy.rivalAscended || (galaxy.rivalAscended = new Set())).add(worldId);
if (state.diplomacy && !state.diplomacy.pacified)
state.diplomacy.stance = Math.min(state.diplomacy.stance, RIVAL_ASCENSION_STANCE_CEILING);
const claims = galaxy.claims || (galaxy.claims = new Map());
const notes = galaxy.expansionNotes || (galaxy.expansionNotes = []);
const faction = state.players.ai ? state.players.ai.faction : "neutral";
claims.set(worldId, faction);
for (const other of galaxy.worlds) {
if (other === worldId || claims.has(other)) continue;
if (other === galaxy.activeId || playerFoothold(galaxy.planets.get(other))) continue;
claims.set(other, faction);
const os = galaxy.planets.get(other);
if (os && os.players.ai) os.players.ai.faction = faction;
notes.push({ type: "expand", from: worldId, planetId: other, faction });
}
}
// The rival-gate scan, run at PROGRESS_CHECK_EVERY cadence from stepGalaxy β€” the same throttled
// schedule checkDomination/checkExpansion/checkGalaxyProgress already share. Three jobs, in order:
// 1. Ascension sweep β€” any AI-owned wonder anywhere at full charge ascends exactly once
// (galaxy.rivalAscended is the idempotency latch), regardless of whether it was the tracked
// world. Fires the singular "rival-gate" milestone the first time this happens anywhere.
// 2. Reapply the stance ceiling for every already-ascended world β€” "permanent" in the same sense
// DOMINATION_TARGET's pacify flag is permanent: reasserted every scan, not a single mutation.
// 3. Selection β€” if nothing is currently tracked, pick the single most-developed world with a
// live (charging, not yet ascended) Gate to surface as THE Rival Gate (galaxyStatus below).
// Iterates `galaxy.worlds` (the fixed roster order), never the Map, so neither insertion order nor
// Map iteration can affect the outcome β€” only aiDevelopment values and the id tie-break can.
export function checkRivalGate(galaxy) {
const ascended = galaxy.rivalAscended || (galaxy.rivalAscended = new Set());
const notes = galaxy.rivalGateNotes || (galaxy.rivalGateNotes = []);
// 1) ASCENSION SWEEP.
for (const worldId of galaxy.worlds) {
if (ascended.has(worldId)) continue;
const state = galaxy.planets.get(worldId);
if (!state) continue;
const b = aiWonderOn(state);
if (!b || (b.charge || 0) < 1) continue;
applyRivalAscension(galaxy, worldId, state);
notes.push({ type: "ascended", planetId: worldId });
reachMilestone(galaxy, "rival-gate");
if (galaxy.rivalGate && galaxy.rivalGate.worldId === worldId) galaxy.rivalGate = null;
}
// 2) REAPPLY the permanent stance ceiling on every ascended world β€” see the header comment on
// why this lives here rather than in engine/diplomacy.js's own drift. Skips a world the player
// has since personally pacified (see the header comment on RIVAL_ASCENSION_STANCE_CEILING) β€”
// conquest overrides a stale ascension, not the other way around.
for (const worldId of ascended) {
const state = galaxy.planets.get(worldId);
if (state && state.diplomacy && !state.diplomacy.pacified)
state.diplomacy.stance = Math.min(state.diplomacy.stance, RIVAL_ASCENSION_STANCE_CEILING);
}
// 3) SELECTION β€” only when nothing is currently tracked. Clear a stale tracked reference first
// (its building razed out from under it without ascending).
if (galaxy.rivalGate) {
const state = galaxy.planets.get(galaxy.rivalGate.worldId);
const b = state && state.buildings.get(galaxy.rivalGate.buildingId);
if (!b || b.owner !== "ai" || !BUILDINGS[b.type]?.wonder || (b.charge || 0) >= 1) {
galaxy.rivalGate = null;
}
}
if (!galaxy.rivalGate) {
let best = null, bestDev = -Infinity, bestBuilding = null;
for (const worldId of galaxy.worlds) {
if (ascended.has(worldId)) continue;
const state = galaxy.planets.get(worldId);
if (!state) continue;
const b = aiWonderOn(state);
if (!b || (b.charge || 0) >= 1) continue;
const dev = aiDevelopment(state);
if (dev > bestDev || (dev === bestDev && best !== null && worldId < best)) { bestDev = dev; best = worldId; bestBuilding = b; }
}
if (best !== null) {
galaxy.rivalGate = { worldId: best, buildingId: bestBuilding.id };
notes.push({ type: "spotted", planetId: best });
}
}
}
// The tracked Rival Gate's live status for the starmap β€” { worldId, charge } or null. Reads the
// CURRENT charge straight off the building (not a stale snapshot), so a caller polling this every
// frame always sees the live number.
function rivalGateStatus(galaxy) {
if (!galaxy.rivalGate) return null;
const state = galaxy.planets.get(galaxy.rivalGate.worldId);
const b = state && state.buildings.get(galaxy.rivalGate.buildingId);
if (!b) return null;
return { worldId: galaxy.rivalGate.worldId, charge: b.charge || 0 };
}
// A pure snapshot of the galaxy for the starmap: per-world status (your active
// seat / a colony you hold / unexplored) and, for worlds you've been to, the
// neighbour's stance. Plus the visited count and credits.
export function galaxyStatus(galaxy) {
// Every world simulates, but the player only SEES one they've REACHED (`discovered`). An
// undiscovered world reads "unexplored" and hides its neighbour's stance, exactly as before the
// living galaxy β€” the difference is now invisible until you visit. Old saves default to the
// instantiated set (which for them was precisely the visited worlds).
const discovered = galaxy.discovered || new Set(galaxy.planets.keys());
return {
credits: galaxy.credits,
activeId: galaxy.activeId,
visited: discovered.size,
total: galaxy.worlds.length,
pacified: galaxy.pacified ? galaxy.pacified.size : 0, // Domination progress: worlds conquered
dominationTarget: DOMINATION_TARGET,
rivalGate: rivalGateStatus(galaxy), // the tracked Rival Gate's { worldId, charge }, or null β€” starmap alert
worlds: galaxy.worlds.map(id => {
const seen = discovered.has(id);
const s = seen ? galaxy.planets.get(id) : null;
let status = "unexplored", income = 0;
const pacified = !!(galaxy.pacified && galaxy.pacified.has(id));
// LABEL AND INCOME ARE DECIDED INDEPENDENTLY. "pacified" outranks colony/contested as a
// label, but it is NOT a reason to report no income: sweepColonies pays a conquered world
// BOTH halves (its buildings' passive income AND the occupation dividend, additively), so
// a `pacified` arm that short-circuited before the income line made the player's best-paying
// world read 0 on the starmap. What's reported here mirrors what sweepColonies actually
// banks, dividend included and background-only β€” the seat earns nothing there
// (`if (!state.background) continue;`) and so must report nothing here.
if (id === galaxy.activeId) status = "seat";
else if (s) {
const buildings = playerBuildingCount(s);
// A world you've been to is a "colony" only while you still hold a
// building there; once razed it's "contested" β€” visited but no longer
// yours (so the map doesn't keep calling a lost world your colony).
status = pacified ? "pacified" : (buildings > 0 ? "colony" : "contested");
income = s.background // credits/min (capped, turret-excluded)
? Math.round((incomeBuildingCount(s) * COLONY_INCOME_PER_BUILDING + (pacified ? PACIFIED_INCOME : 0)) * 60)
: 0;
}
else if (pacified) status = "pacified"; // conquered but not in `discovered` (so `s` is null) β€” still label it
// Industry/Tech ratings (data.js) drive factory speed + research speed and
// finished-good prices β€” surfaced so "where to settle/jump" is an informed call.
const p = PLANETS.find(pl => pl.id === id);
// `faction` is the world's fixed cultural origin (data.js PLANETS); `controlledBy` is the DYNAMIC
// faction that has claimed it via expansion (checkExpansion) β€” the spreading galactic politics
// shown on the starmap. Visible galaxy-wide (it's sphere-of-influence news, not tactical intel).
return { id, status, income, pacified, stance: seen && s && s.diplomacy ? s.diplomacy.stance : null,
industry: p ? p.industry : 5, tech: p ? p.tech : 5, faction: p ? p.faction : null,
controlledBy: (galaxy.claims && galaxy.claims.get(id)) || null };
}),
};
}
// The fortified Capital: an upgraded Command Center with double HP. Like every deployed
// base it is permanent β€” it never travels (interplanetary jumps carry a colony ship, not
// a base; jumpVessel/jumpCapital) β€” so the Capital is your hardened anchor world. One
// per owner; the flag also drives its gold ring on the map (render.js).
export const CAPITAL_UPGRADE_COST = { ore: 400 };
export const CAPITAL_HP_MULT = 2;
// Improve a Command Center into your Capital: pay the cost, mark it, and scale its HP
// (and current HP, preserving any battle damage as a fraction) by CAPITAL_HP_MULT.
// Odyssey-only, one Capital per owner, not on a still-constructing CC. Deterministic β€”
// pure state mutation, no clock/RNG. Returns whether the upgrade happened.
export function upgradeToCapital(state, building) {
if (!building || building.type !== "command" || building.constructing || building.capital) return false;
const owner = building.owner;
for (const b of state.buildings.values())
if (b.owner === owner && b.capital) return false; // only one Capital
const res = state.players[owner].resources;
for (const com in CAPITAL_UPGRADE_COST) if ((res[com] || 0) < CAPITAL_UPGRADE_COST[com]) return false;
for (const com in CAPITAL_UPGRADE_COST) res[com] -= CAPITAL_UPGRADE_COST[com];
building.capital = true;
building.maxHp = Math.round(building.maxHp * CAPITAL_HP_MULT);
building.hp = Math.round(building.hp * CAPITAL_HP_MULT); // keeps the damage fraction
return true;
}
// The Spaceport comes in THREE tiers. Its jump capacity β€” how much fleet it can launch
// at once, measured in ship POPULATION (supply cost), not head-count β€” scales with the
// tier, so a very large army has to cross in several jumps through a small pad, or you
// upgrade to a bigger one. Capacity is indexed by tier (1..3); index 0 is unused.
export const SPACEPORT_MAX_TIER = 3;
export const SPACEPORT_CAPACITY = [0, 12, 24, 40]; // supply carried per jump, by tier
export const SPACEPORT_UPGRADE_COST = { 2: { ore: 250 }, 3: { ore: 500 } }; // ore to reach a tier (escalating)
// A Spaceport's tier (defaults to 1 for a fresh pad or a pre-tier save) and the per-jump
// capacity it grants.
export const spaceportTier = b => Math.min(SPACEPORT_MAX_TIER, Math.max(1, b.tier || 1));
export const jumpCapacity = b => SPACEPORT_CAPACITY[spaceportTier(b)];
// Upgrade a Spaceport one tier (max 3): pay the ore, bump the tier, raising its per-jump
// capacity. Odyssey-only, deterministic (pure state mutation β€” no clock/RNG), like the
// Capital fortification. Returns whether it happened (refused when already max, still
// under construction, or unaffordable).
export function upgradeSpaceport(state, building) {
if (!building || building.type !== "spaceport" || building.constructing) return false;
const tier = spaceportTier(building);
if (tier >= SPACEPORT_MAX_TIER) return false;
const cost = SPACEPORT_UPGRADE_COST[tier + 1];
const res = state.players[building.owner].resources;
for (const com in cost) if ((res[com] || 0) < cost[com]) return false;
for (const com in cost) res[com] -= cost[com];
building.tier = tier + 1;
return true;
}
const unitSupply = u => UNITS[u.type]?.supplyCost || 0;
// What counts as a launch-ready pad: the player's own, finished Spaceport. Defined once so
// jumpVessel/canJump/jumpCapital can't drift on the predicate (owner + type + built).
const isPlayerSpaceport = b => b.owner === "player" && b.type === "spaceport" && !b.constructing;
// EVERY launch-ready pad the player holds on this world, not just one β€” a world can carry
// more than one completed Spaceport (a second pad built for extra jump throughput, or while
// upgrading the first). Sorted by id for a stable, deterministic iteration order (matches the
// codebase's usual entity tie-break), so which pad "goes first" below can never depend on Map
// insertion order.
export const playerSpaceports = state => [...state.buildings.values()].filter(isPlayerSpaceport).sort((a, b) => (a.id < b.id ? -1 : 1));
// The colony ship that would carry an interplanetary jump: a player colony ship staged within
// JUMP_LOAD_RADIUS of ANY completed Spaceport (not just one β€” see playerSpaceports). A jump
// relocates the SHIP (and the rest of the staged expedition) β€” NOT a deployed base. Deployed
// Command Centers are permanent: the world you leave keeps them and becomes a background
// colony. Deploy the ship at the destination to found your new base there. Null when no ship
// is on any pad.
export function jumpVessel(state) {
const spaceports = playerSpaceports(state);
if (!spaceports.length) return null;
for (const u of state.units.values())
if (u.owner === "player" && u.type === "colonyship"
&& spaceports.some(sp => Math.hypot(u.x - sp.x, u.y - sp.y) <= JUMP_LOAD_RADIUS)) return u;
return null;
}
// Can the player launch a jump from this world? β€” just a completed Spaceport. No colony
// ship is required: a jump can carry one (to settle a new world), or an army (to reinforce
// a colony), or nothing (to hop back and control a world you already hold). jumpVessel
// stays as an informational helper (is a ship loaded?) for the HUD, not a gate.
export function canJump(state) {
return playerSpaceports(state).length > 0;
}
// A world where the player still has a foothold β€” a Command Center or an undeployed colony ship
// (the same notion as the galaxy defeat/relief check). It's a world a stranded force can fall
// back to and actually operate from: build, re-arm, or fetch a colony ship.
const playerFoothold = state => !!state && ([...state.buildings.values()]
.some(b => b.owner === "player" && b.type === "command") || hasColonyShip(state, "player"));
// Can the player jump to `destId` right now? A Spaceport on the CURRENT world lets you jump
// anywhere (expand to a new world or hop to a held one). WITHOUT a Spaceport here you can still
// FALL BACK to any world where you have a foothold β€” so a force stranded on a portless world
// (e.g. you hopped an army over and forgot the colony ship) is never trapped: it can always
// retreat to a base it holds and bring the ship back. Only opening a NEW frontier needs a
// Spaceport here.
export function canJumpTo(galaxy, destId) {
if (destId === galaxy.activeId) return false;
return canJump(activeState(galaxy)) || playerFoothold(galaxy.planets.get(destId));
}
const planetX = id => PLANETS.find(p => p.id === id)?.x ?? 0;
// The Spaceport's tier doesn't just lift more fleet per jump (SPACEPORT_CAPACITY) β€” a bigger
// pad also burns new-world fuel more efficiently. Indexed by tier (1..3) like SPACEPORT_CAPACITY;
// index 0 (no completed pad at all) is the same x1.0 as a fresh Tier-1 pad, so building the FIRST
// Spaceport is a pure capability unlock and never itself a hidden fuel bump.
export const FUEL_DISCOUNT_BY_TIER = [1, 1, 0.85, 0.7];
// The fuel a jump to `destId` costs: FREE to a world you already hold (any world you've
// visited β€” a colony you're returning to, reinforcing, or re-settling), so bouncing between
// your own worlds to defend or ferry a colony ship stays friction-free. Reaching a NEW world
// costs fuel that SCALES WITH DISTANCE across the frontier (data.js planet x, 0..~18): a near
// hop is close to the base fee, settling a distant world is a real, growing credit sink and a
// strategic choice β€” so exploration spend isn't the old flat, quickly-capped ~4,000 lifetime.
// The origin's best COMPLETED Spaceport tier (playerSpaceports(from), not the jump's actual
// destination pad β€” this is about YOUR launch infrastructure) cuts that distance-scaled fuel by
// FUEL_DISCOUNT_BY_TIER, so upgrading the pad is a real economic choice, not just a capacity knob.
export function jumpCost(galaxy, destId) {
// Free to a world you've already REACHED (a colony you're returning to, reinforcing, or
// re-settling). Since the living galaxy instantiates every world up front, "reached" is the
// player-facing `discovered` set, NOT merely "the state object exists" β€” else every jump would be
// free. Old saves (no discovered set) fall back to planets.has, which for them meant the same thing.
const known = galaxy.discovered ? galaxy.discovered.has(destId) : galaxy.planets.has(destId);
if (known || playerFoothold(galaxy.planets.get(destId))) return 0; // reached before, or a base you still hold β†’ free
const dist = Math.abs(planetX(destId) - planetX(galaxy.activeId));
const tier = playerSpaceports(activeState(galaxy)).reduce((max, sp) => Math.max(max, spaceportTier(sp)), 0);
const discount = FUEL_DISCOUNT_BY_TIER[tier] ?? 1;
return Math.round(JUMP_COST * discount * (0.8 + dist / 18)); // ~340 next-door … ~720 across the map, before the pad discount
}
// The player units staged near a Spaceport β€” the expedition that rides along on a
// jump. One definition, so the HUD's preview count and the jump's actual move can
// never disagree about what leaves.
export function stagedRiders(state, spaceport) {
const out = [];
for (const u of state.units.values())
// A ship assigned to a Freight Lane (u.laneId, see runLanes below) is physically parked here
// as a STANDING investment, not a free ride β€” excluded so it never gets swept off-world on the
// next jump out from under its lane.
if (u.owner === "player" && !u.laneId && Math.hypot(u.x - spaceport.x, u.y - spaceport.y) <= JUMP_LOAD_RADIUS) out.push(u);
return out;
}
// What actually LAUNCHES on one jump, capped by the pad's tier capacity. From the units
// staged near the Spaceport, fill the hold closest-to-the-pad first by ship population
// (supply) until the next unit wouldn't fit β€” a skip-not-break fill, so a heavy unit that
// doesn't fit is passed over for lighter ones behind it rather than blocking them (workers
// are supply 1, so a fleet always makes progress and nothing softlocks). The overflow
// waits at the pad for the next jump. Pure + deterministic: closest-first, ties broken by
// entity id. One definition, so the HUD preview and the jump's actual move always agree.
export function jumpManifest(state, spaceport) {
const capacity = jumpCapacity(spaceport);
const staged = stagedRiders(state, spaceport)
.map(u => ({ u, d: Math.hypot(u.x - spaceport.x, u.y - spaceport.y) }))
.sort((a, b) => a.d - b.d || (a.u.id < b.u.id ? -1 : 1));
const riders = [];
let used = 0;
for (const { u } of staged) {
const s = unitSupply(u);
if (used + s <= capacity) { riders.push(u); used += s; }
}
const stagedSupply = staged.reduce((t, { u }) => t + unitSupply(u), 0);
return { riders, capacity, used, stagedSupply, staged: staged.length, leftBehind: staged.length - riders.length };
}
// EVERY player Spaceport's staged riders, combined into one launch. With a single completed
// pad this is byte-identical to jumpManifest(state, thatPad) β€” every candidate unit's nearest
// pad is trivially the one pad, so the same closest-first fill runs unchanged. With more than
// one pad, each staged unit boards through its NEAREST pad (ties broken by pad id, matching
// the unit tie-break below), and each pad fills its own capacity independently β€” a unit that
// doesn't fit at its nearest pad is left behind rather than spilling over to another pad's
// spare capacity (a simple, deterministic policy, not a globally-optimal pack across pads).
// jumpCapital is the only caller: this is what actually determines who jumps, so it has to
// count every completed pad, not just one β€” a second Spaceport's staged units used to be
// silently ignored on every jump, because only the first Spaceport found (Map iteration
// order) was ever consulted.
export function jumpManifestAll(state) {
const spaceports = playerSpaceports(state);
if (!spaceports.length) return { riders: [], capacity: 0, used: 0, stagedSupply: 0, staged: 0, leftBehind: 0 };
// Every player unit within range of at least one pad, tagged with its NEAREST pad so it can
// never be double-counted across two overlapping catchment radii.
const byNearestPad = new Map(spaceports.map(sp => [sp.id, []]));
for (const u of state.units.values()) {
// A Freight Lane's assigned ship is busy standing infrastructure, not a jump rider β€” see
// stagedRiders' identical exclusion above; jumpManifestAll can't just call stagedRiders (it
// needs the nearest-pad tagging below), so the check is repeated here.
if (u.owner !== "player" || u.laneId) continue;
let nearest = null, nearestD = Infinity;
for (const sp of spaceports) {
const d = Math.hypot(u.x - sp.x, u.y - sp.y);
if (d > JUMP_LOAD_RADIUS) continue;
if (d < nearestD || (d === nearestD && sp.id < nearest.id)) { nearest = sp; nearestD = d; }
}
if (nearest) byNearestPad.get(nearest.id).push({ u, d: nearestD });
}
const riders = [];
let capacity = 0, used = 0, stagedSupply = 0, staged = 0;
for (const sp of spaceports) {
const cap = jumpCapacity(sp);
capacity += cap;
const candidates = byNearestPad.get(sp.id).sort((a, b) => a.d - b.d || (a.u.id < b.u.id ? -1 : 1));
let padUsed = 0;
for (const { u } of candidates) {
const s = unitSupply(u);
if (padUsed + s <= cap) { riders.push(u); padUsed += s; }
}
used += padUsed;
staged += candidates.length;
stagedSupply += candidates.reduce((t, { u }) => t + unitSupply(u), 0);
}
return { riders, capacity, used, stagedSupply, staged, leftBehind: staged - riders.length };
}
// A jump carries a CARGO HOLD of manufactured goods to the destination, so a run of production on
// one world can be sold on another β€” the make-here/sell-there loop (produced goods price
// differently per world, engine/market.js). The hold's SIZE is the combined cargoHold of the cargo
// ships (engine/entities.js: hauler/heavyhauler/bulkfreighter) staged for the jump β€” no cargo ship
// means no freight. Loaded most-valuable-first (data.js COM.base: machinery 250 > electronics 95 >
// alloys 80 > spice 34 > metals 22). spice is here so Verdani's agri surplus can be exported and
// sold dear on an industrial world (it's cheap where it's mined, precious where it isn't); cheaper
// raws aren't worth a cargo slot and strategic goods stay put.
// Exported so runLanes' commodity-filter fallback (below) and any future caller can share the
// exact same default ordering cargoManifest already uses β€” one most-valuable-first list, not two
// that could drift.
export const CARGO_GOODS = ["machinery", "electronics", "alloys", "spice", "metals"];
// The freight capacity a set of riders provides β€” the summed cargoHold of the cargo ships among
// them (anything without a cargoHold carries nothing). Pure.
export function freightCapacity(riders) {
let cap = 0;
for (const u of riders) cap += UNITS[u.type]?.cargoHold || 0;
return cap;
}
// What `capacity` units of hold would haul from `from`, as { good: qty } β€” most-valuable-first, for
// the HUD preview and the jump itself (so the shown manifest and the moved goods can never
// disagree). capacity 0 (no cargo ship staged) β†’ an empty hold. `goods` lets a caller restrict the
// pick to a subset (runLanes' commodity filter, below) while walking CARGO_GOODS' own
// most-valuable-first order; omitted, it's the exact same list every existing caller already got.
export function cargoManifest(from, capacity = 0, goods = CARGO_GOODS) {
let room = Math.max(0, capacity | 0);
const src = from.players.player.resources;
const manifest = {};
for (const com of goods) {
if (room <= 0) break;
const move = Math.min(Math.floor(src[com] || 0), room);
if (move > 0) { manifest[com] = move; room -= move; }
}
return manifest;
}
/* ---------- Freight Lanes: standing shipping between held worlds ---------- */
// How often (in galaxy ticks) an active lane moves cargo β€” an integer schedule keyed on
// galaxy.tick, exactly like BG_STEP/PROGRESS_CHECK_EVERY above, so it's deterministic and
// independent of frame dt. A lane is a slow drip (real sim minutes, at the 20Hz cadence the rest
// of this scheduler assumes), not an instant transfer β€” it's standing infrastructure, not a
// one-off haul.
export const LANE_PERIOD = 200; // ~10 sim-seconds at 20Hz
// Create a standing lane from `from` to `to`, filtered to `commodities` (empty/omitted = every
// CARGO_GOODS commodity, most-valuable-first β€” see runLanes). Refused for a same-world "lane" or
// an unrecognised world on either end. Returns the new lane (also pushed onto galaxy.lanes), or
// null on refusal.
export function createLane(galaxy, from, to, commodities = []) {
if (from === to || !galaxy.planets.has(from) || !galaxy.planets.has(to)) return null;
const lanes = galaxy.lanes || (galaxy.lanes = []);
const lane = { id: "lane" + (galaxy.laneSeq = (galaxy.laneSeq || 0) + 1), from, to, commodities: [...commodities], shipIds: [] };
lanes.push(lane);
return lane;
}
// Disband a lane and free every ship it held busy (clears their laneId flag). Returns whether a
// lane with that id existed.
export function deleteLane(galaxy, laneId) {
const lanes = galaxy.lanes;
if (!lanes) return false;
const idx = lanes.findIndex(l => l.id === laneId);
if (idx < 0) return false;
const lane = lanes[idx];
const from = galaxy.planets.get(lane.from);
if (from) for (const id of lane.shipIds) { const u = from.units.get(id); if (u) delete u.laneId; }
lanes.splice(idx, 1);
return true;
}
// Assign a player freighter parked on the lane's SOURCE world to it β€” it must be a real cargo
// ship (UNITS[type].cargoHold) and currently standing within JUMP_LOAD_RADIUS of one of that
// world's own completed Spaceports (reusing the exact same catchment jumpVessel/stagedRiders use
// β€” this is deliberately the same "at the pad" test, not a new one). A ship already on another
// lane is moved over rather than double-booked. Returns whether the assignment happened.
export function assignShipToLane(galaxy, laneId, unitId) {
const lane = (galaxy.lanes || []).find(l => l.id === laneId);
if (!lane) return false;
const from = galaxy.planets.get(lane.from);
const u = from && from.units.get(unitId);
if (!u || u.owner !== "player" || !UNITS[u.type]?.cargoHold) return false;
const pads = playerSpaceports(from);
if (!pads.length || !pads.some(p => Math.hypot(u.x - p.x, u.y - p.y) <= JUMP_LOAD_RADIUS)) return false;
if (u.laneId && u.laneId !== laneId) unassignShipFromLane(galaxy, u.laneId, unitId);
if (!lane.shipIds.includes(unitId)) lane.shipIds.push(unitId);
u.laneId = laneId;
return true;
}
// Pull a ship off a lane, freeing it for jumps/other orders again. Returns whether it was found.
export function unassignShipFromLane(galaxy, laneId, unitId) {
const lane = (galaxy.lanes || []).find(l => l.id === laneId);
if (!lane) return false;
const idx = lane.shipIds.indexOf(unitId);
if (idx < 0) return false;
lane.shipIds.splice(idx, 1);
const from = galaxy.planets.get(lane.from);
const u = from && from.units.get(unitId);
if (u && u.laneId === laneId) delete u.laneId;
return true;
}
// Run every standing lane one delivery cycle: revalidate its assigned ships (still alive, still a
// player freighter, still parked near one of the source world's own Spaceports β€” exactly
// assignShipToLane's own test, run again every cycle so a ship that wandered off, died, or was
// reassigned quietly drops out rather than silently keep "shipping" from wherever it ended up),
// then move min(the survivors' combined cargoHold, the filtered stock on hand) straight from the
// source world's treasury to the destination's β€” a lane never touches a ship's own .freight hold,
// it's a capacity NUMBER standing in for "how much this route can carry today", not a physical
// load/unload cycle. Called from stepGalaxy on the LANE_PERIOD schedule. Deterministic: galaxy.lanes
// and each lane's shipIds are plain arrays in insertion order, and cargoManifest's own commodity
// walk is a fixed list β€” nothing here reads a clock, RNG, or Map/Set iteration order.
export function runLanes(galaxy) {
const lanes = galaxy.lanes;
if (!lanes || !lanes.length) return;
for (const lane of lanes) {
const from = galaxy.planets.get(lane.from);
const to = galaxy.planets.get(lane.to);
if (!from || !to) continue;
const pads = playerSpaceports(from);
const kept = [];
for (const id of lane.shipIds) {
const u = from.units.get(id);
const valid = u && u.owner === "player" && UNITS[u.type]?.cargoHold
&& pads.some(p => Math.hypot(u.x - p.x, u.y - p.y) <= JUMP_LOAD_RADIUS);
if (valid) { u.laneId = lane.id; kept.push(id); }
else if (u) delete u.laneId;
}
lane.shipIds = kept;
if (!kept.length) continue;
const capacity = kept.reduce((sum, id) => sum + (UNITS[from.units.get(id).type]?.cargoHold || 0), 0);
if (capacity <= 0) continue;
const goods = lane.commodities && lane.commodities.length ? lane.commodities : CARGO_GOODS;
const manifest = cargoManifest(from, capacity, goods);
const src = from.players.player.resources, dst = to.players.player.resources;
for (const com in manifest) {
src[com] -= manifest[com];
dst[com] = (dst[com] || 0) + manifest[com];
}
}
}
// freightUsed/freightRoom (how full a freighter's hold is, and its remaining room) now live in
// entities.js β€” engine/haul.js needs them for the worker↔freighter physical loading path and can't
// import this module (galaxy.js -> sim.js -> haul.js would cycle back here). Re-exported (imported
// above) so every existing caller of these two from "./galaxy.js" is unaffected.
export { freightUsed, freightRoom };
// Load up to `qty` of commodity `com` from the CURRENT world's player stockpile into a freighter's
// hold, clamped to the hold's room and what's actually in stock. Returns the amount loaded (0 if
// it's not a player freighter, the commodity is unknown, or nothing could move). The goods leave
// the stockpile immediately β€” they're aboard the ship now, to ride the next jump.
export function loadFreighter(state, unitId, com, qty) {
const u = state.units.get(unitId);
if (!u || u.owner !== "player" || !u.freight || !COM[com] || !(UNITS[u.type]?.cargoHold)) return 0;
const res = state.players.player.resources;
const move = Math.min(Math.floor(qty) || 0, freightRoom(u), Math.floor(res[com] || 0));
if (move <= 0) return 0;
res[com] -= move;
u.freight[com] = (u.freight[com] || 0) + move;
return move;
}
// Unload up to `qty` of `com` from a freighter's hold back onto the CURRENT world's player
// stockpile. Returns the amount unloaded. Works wherever the ship is β€” the origin (to undo a
// load) or a world it jumped to (to bank the haul and sell it at that market).
export function unloadFreighter(state, unitId, com, qty) {
const u = state.units.get(unitId);
if (!u || u.owner !== "player" || !u.freight) return 0;
const move = Math.min(Math.floor(qty) || 0, u.freight[com] || 0);
if (move <= 0) return 0;
u.freight[com] -= move;
if (u.freight[com] <= 0) delete u.freight[com];
const res = state.players.player.resources;
res[com] = (res[com] || 0) + move;
return move;
}
// A jump delivers every staged freighter's HOLD to the destination colony's stockpile β€” exactly
// what the player hand-loaded (loadFreighter/unloadFreighter), nothing more. A freighter left
// empty carries nothing across; it does NOT auto-fill from the origin stockpile any more (the
// previous "empty hold β†’ most-valuable-first" default made a round trip actively work against a
// player who wanted to haul on the way back only: land empty at a destination, jump home before
// reloading, and the empty ship would eagerly scoop up local goods and carry them right back,
// silently undoing a delivery the player had just made β€” see the reported case in git history).
// Manual control now means exactly that: load what you want shipped, or it stays put. Returns the
// combined delivered manifest (for the arrival toast). `riders` are the units that lifted off;
// non-freighters (no hold) are skipped.
function loadCargo(from, dest, riders) {
const dst = dest.players.player.resources;
const delivered = {};
for (const u of riders) {
if (!u.freight || !(UNITS[u.type]?.cargoHold)) continue;
for (const com in u.freight) { // deliver the hold to the destination colony
dst[com] = (dst[com] || 0) + u.freight[com];
delivered[com] = (delivered[com] || 0) + u.freight[com];
}
u.freight = {};
}
return delivered;
}
// A minimap pick is necessarily coarse β€” that's the whole point of it (a blind landing on a
// world with no beacon should feel like one, not a precise coordinate entry). So a pick resolves
// to the NEAREST ALLOWED LANDING SITE: a fixed grid of them across the world's middle, plus the
// margin ring itself at each edge. The margin comfortably exceeds the widest rider-spawn ring
// jumpCapital uses below (46 + 2*18 = 82), so a picked corner can never spawn a unit off the map.
//
// NEAREST-SITE, NOT ROUND-THEN-CLAMP. This used to round onto the grid and then clamp into the
// margin, which made it NOT IDEMPOTENT: the clamp bound (100) is not a multiple of the grid (160),
// so the whole edge band collapsed onto an off-lattice 100 that re-snapped to 160. Feeding the
// function its own output moved the point again β€” 161 of the 1601 x-values on a 1600-wide map.
// That is a live trap for callers, because the obvious way to build a picker is "snap to show the
// player where they'll land, then send THAT point": the confirmed point got snapped a second time
// and the colony touched down 60 units from the ring the player was looking at, with nothing on
// screen to say so. (landingPicker.js dodges it by forwarding the RAW point and snapping once β€”
// which is no longer something a caller has to know.)
//
// Choosing the nearest site instead fixes that WITHOUT changing where anyone can land: the set of
// reachable outputs is exactly what round-then-clamp already produced β€” {margin, ...grid...,
// span-margin} β€” so no landing area is lost or gained, and no balance changes. All that changes is
// which clicks map to which site, and only inside the edge bands, where a click now resolves to
// the site actually closest to it (x=90 lands at 100, where it used to skip out to 160). Every
// output is a fixed point, so snap(snap(p)) === snap(p) everywhere.
export const LANDING_PICK_GRID = 160;
const LANDING_PICK_MARGIN = 100;
// Every landing site along one axis of length `span`, ascending: the near margin, the grid points
// strictly inside it, and the far margin. Exported through landingSites below so a picker can OFFER
// these rather than guess at them β€” "the grid" alone is not the answer (a click at 90 belongs to
// the margin site at 100, not to the grid point at 160), which is exactly the kind of 60-unit-wrong
// lattice a caller would otherwise build for itself.
function axisLandingSites(span) {
const lo = LANDING_PICK_MARGIN, hi = span - LANDING_PICK_MARGIN;
// A world narrower than its own two margins has no valid band at all. Nothing generates one
// (engine/map.js maps are far wider), but this is reachable from any caller's map object, and a
// single fixed site keeps the "every output is a fixed point" promise true even there.
if (hi <= lo) return [Math.round(span / 2)];
const sites = [lo];
for (let v = Math.ceil(lo / LANDING_PICK_GRID) * LANDING_PICK_GRID; v < hi; v += LANDING_PICK_GRID)
if (v > lo) sites.push(v);
sites.push(hi);
return sites;
}
// The allowed landing sites for `map`, as the two axes' independent site lists (any xs Γ— ys pair is
// a valid landing point). Purely derived β€” no clock, no RNG, no state.
export function landingSites(map) {
return { xs: axisLandingSites(num(map.width)), ys: axisLandingSites(num(map.height)) };
}
export function snapLandingPoint(map, x, y) {
// Nearest site wins; a tie goes to the FARTHER-OUT one, matching the way the old Math.round
// broke its own half-way ties upward (a click exactly between two sites is arbitrary either way).
const snap = (v, sites) => {
let best = sites[0];
for (const s of sites) if (Math.abs(v - s) <= Math.abs(v - best)) best = s;
return best;
};
const { xs, ys } = landingSites(map);
return { x: snap(num(x), xs), y: snap(num(y), ys) };
}
function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
// Where a jump's riders actually touch down on `dest`. In priority order:
// 1. The player's own Spaceport most recently used to LAND here (`lastLanding`, a galaxy.time
// stamp β€” see below) β€” a return trip comes in at your own infrastructure, not the world's
// fixed generation-time anchor, which could by now be nowhere near where you actually
// built. Ties (never-landed-at pads) fall to the lowest id, same as every other tie-break
// in this file.
// 2. No Spaceport stands on `dest` at all (never settled, or one stood here and was since
// razed): there's no beacon to home in on, so the player chose an approximate spot
// themselves (the landingPicker.js "blind minimap" screen) β€” `landingPoint`, already
// fuzzed by snapLandingPoint above before it ever reaches here.
// 3. Neither β€” an old save, or a caller that skipped the picker: the planet's fixed
// generation-time anchor, exactly today's (pre-this-feature) behaviour.
function landingZone(dest, landingPoint) {
const pads = playerSpaceports(dest);
if (pads.length) {
let best = pads[0];
for (const p of pads) if ((p.lastLanding || 0) > (best.lastLanding || 0)) best = p;
return { x: best.x, y: best.y, pad: best };
}
if (landingPoint && Number.isFinite(landingPoint.x) && Number.isFinite(landingPoint.y)) {
const p = snapLandingPoint(dest.map, landingPoint.x, landingPoint.y);
return { x: p.x, y: p.y, pad: null };
}
return { x: dest.map.bases.player.x, y: dest.map.bases.player.y, pad: null };
}
// Launch an interplanetary jump to `destId`: player units move to the destination's landing
// zone along with the cargo hold. NO deployed base moves β€” the origin keeps ALL its buildings
// and becomes a background colony. Costs fuel only for a NEW world (jumpCost); returning to a
// held world is free.
//
// Two modes:
// β€’ With a Spaceport HERE β€” the normal launch: the capacity-capped expedition staged near the
// pad (a colony ship to settle, an army to reinforce, or nothing) rides along.
// β€’ WITHOUT a Spaceport, falling back to a world you ALREADY HOLD β€” a pure CONTROL SWITCH that
// moves NO units: with no launch pad there's no way to load a fleet, so the force you left on
// this world STAYS PUT. This is the catch-22 escape (hop back to a base to fetch a colony
// ship) WITHOUT dragging a garrison home β€” you bring the ship to the stranded force, not the
// force back. To actually ferry units off a world, build a Spaceport there. See canJumpTo.
//
// `opts.landingPoint` (world coords) is consulted ONLY when `dest` has no player Spaceport of
// its own β€” see landingZone above. Ignored (harmlessly) whenever a pad is standing, and never
// required: every other case falls back cleanly on its own.
//
// Returns a summary, or null if the jump can't run (no way to reach the destination, same world,
// or too poor to fuel a new-world jump).
export function jumpCapital(galaxy, destId, opts = {}) {
const from = activeState(galaxy);
if (destId === galaxy.activeId) return null;
const hasSpaceport = playerSpaceports(from).length > 0;
const canFallBack = playerFoothold(galaxy.planets.get(destId)); // a world you hold a base on
if (!hasSpaceport && !canFallBack) return null; // no port here and nowhere to fall back β†’ can't jump
const cost = jumpCost(galaxy, destId);
if (galaxy.credits < cost) return null;
// A Spaceport (or several β€” every completed pad's staged units ride, not just one) β†’ the
// combined capacity-capped expedition. No Spaceport (a fallback) β†’ nothing rides: a control
// switch that leaves every unit on this world where it is.
let riders = [], leftBehind = 0;
if (hasSpaceport) ({ riders, leftBehind } = jumpManifestAll(from));
galaxy.credits -= cost; // fuel β€” free to a world you already hold
const dest = galaxy.planets.get(destId) || addPlanet(galaxy, destId, { unsettled: true });
const { x: lzx, y: lzy, pad } = landingZone(dest, opts.landingPoint);
const nextId = () => "g" + (galaxy.entitySeq = (galaxy.entitySeq || 0) + 1); // fresh ids: no cross-state collision
riders.forEach((u, i) => {
from.units.delete(u.id);
const a = (i / Math.max(1, riders.length)) * Math.PI * 2, ring = 46 + (i % 3) * 18;
u.id = nextId(); u.x = lzx + Math.cos(a) * ring; u.y = lzy + Math.sin(a) * ring;
u.order = null; u.orderQueue = [];
dest.units.set(u.id, u);
});
// Remember this pad for the NEXT return trip (landingZone above prefers whichever pad has
// the highest `lastLanding`). Only worth stamping when riders actually landed β€” an empty
// control-switch hop (see the file header comment) doesn't touch anything here.
if (pad && riders.length) pad.lastLanding = galaxy.time || 0;
const cargo = loadCargo(from, dest, riders); // deliver each staged freighter's hold β€” hand-loaded only
from.selection = []; dest.selection = [];
from.background = true; // the world you left keeps evolving on its own
dest.background = false; // the destination is now your active seat
galaxy.activeId = destId;
(galaxy.discovered || (galaxy.discovered = new Set())).add(destId); // now REACHED β€” a free return-jump, and it shows on the starmap
updateFog(dest, dest.fog, "player");
updateFog(dest, dest.fogAI, "ai");
updateFog(from, from.fog, "player");
return { destId, riders: riders.length, leftBehind, cargo }; // leftBehind: staged units the pad couldn't fit this trip
}