fertig-classic-games/src/games/mastervega/VegaLogic.js

2962 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Master of Vega — the engine. Headless: no Phaser, importable by Node, so
// tools/verifyMasterOfVega.js can play whole games without a browser.
//
// Every function takes (rules, state, ...). `state` is plain JSON with the RNG
// cursor inside it, so serialising the state serialises the future too: replay
// a seed and you get the same galaxy, the same battles, the same winner.
//
// Economy model: MOO1's five allocation sliders, with MOO2 colony buildings
// acting as multipliers on the channel they name. The "Construction" channel
// funds the colony build queue (ships AND buildings); Industry builds factories;
// Ecology cleans industrial waste; Defence raises planetary defences; Research
// splits into the six tech fields.
import {
generateGalaxy, parsecs, mulberry32, PARSEC_PX,
} from './VegaGalaxyGen.js';
import { techCost, techCostFactor } from './VegaRules.js';
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
// Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only
// prototype) — Brian's explicit call, after V2's momentum/collision-avoidance/
// formation work matured and its per-tick cost was brought down (see
// docs/mastervega-build-plan.md's "V2 becomes the official combat engine"
// entry). resolveInvasion (ground combat) is untouched — it has no
// battle-shaped dependency on either engine.
import { createBattle, runBattle } from './VegaCombatV2.js';
import { resolveInvasion } from './VegaCombat.js';
import { breakStalemate, declareWar, runGalaxyDiplomacyPass } from './VegaDiplomacy.js';
export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research'];
// --------------------------------------------------------------------------
// RNG — explicit state so it serialises with the game.
export function rand(state) {
let a = state.rngState | 0;
a = (a + 0x6d2b79f5) | 0;
state.rngState = a;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function randInt(state, n) { return Math.floor(rand(state) * n); }
export function attachRules(state, rules) { state.rules = rules; return state; }
// `seq` is a permanent, ever-increasing id independent of the event's
// position in `state.events` — VegaAI.js's updateWarMemory (per-empire
// combat-outcome tracking) needs a cursor that survives the trim below
// unscathed; an array-index or count-based cursor would silently go stale
// (or worse, skip real events) the moment a trim ran between two reads.
const pushEvent = (state, ev) => {
ev.seq = state.nextEventSeq += 1;
state.events.push(ev);
if (state.events.length > 600) state.events = state.events.slice(-300);
};
// --------------------------------------------------------------------------
// Lookups
export const empireColonies = (state, e) => state.colonies.filter((c) => c.empireIdx === e);
export const empireFleets = (state, e) => state.fleets.filter((f) => f.empireIdx === e);
export const colonyAt = (state, starIdx) => state.colonies.find((c) => c.starIdx === starIdx);
export const coloniesAt = (state, starIdx) => state.colonies.filter((c) => c.starIdx === starIdx);
/**
* Colonies grouped by star for one empire, star groups ordered by that
* star's total population (highest first) — the shape the Colonies
* screen's spreadsheet (VegaColoniesScreen.js) needs. Colonies within a
* group are also ordered by population, highest first.
*/
export function empireColoniesByStar(state, e) {
const groups = new Map();
for (const colony of empireColonies(state, e)) {
let g = groups.get(colony.starIdx);
if (!g) { g = { starIdx: colony.starIdx, colonies: [], totalPop: 0 }; groups.set(colony.starIdx, g); }
g.colonies.push(colony);
g.totalPop += colony.pop;
}
const out = [...groups.values()];
out.forEach((g) => g.colonies.sort((a, b) => b.pop - a.pop));
out.sort((a, b) => b.totalPop - a.totalPop);
return out;
}
export const fleetsAt = (state, starIdx) => state.fleets.filter((f) => f.starIdx === starIdx);
export const starOf = (state, i) => state.galaxy.stars[i];
export const planetOf = (state, colony) => state.galaxy.stars[colony.starIdx].planets[colony.orbit];
// designFor() runs a knapsack per call. The AI, the upkeep pass and the fleet
// panel all ask for the same handful of designs many times per turn, so they
// are memoised per empire and invalidated whenever a tech lands.
export function empireDesign(rules, state, e, hullId, skills = null) {
const emp = state.empires[e];
if (skills) return designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits, skills);
if (!emp._designs || emp._designsAt !== emp.techsKnown) {
emp._designs = {};
emp._designsAt = emp.techsKnown;
}
if (!emp._designs[hullId]) {
emp._designs[hullId] = designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits);
}
return emp._designs[hullId];
}
export function empireComponents(rules, state, e) {
const emp = state.empires[e];
if (!emp._comps || emp._compsAt !== emp.techsKnown) {
emp._comps = bestComponents(rules, emp.known);
emp._compsAt = emp.techsKnown;
}
return emp._comps;
}
// Leader skill bags, merged. Admins bind to one colony; captains bind to a
// fleet. Anything unassigned still costs upkeep but does nothing, which is the
// player's problem.
export function colonyLeaderSkills(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const l = emp.leaders.find((x) => x.assignKind === 'colony' && x.assignId === colony.id);
return l ? rules.leaders[l.leaderId].skills : {};
}
export function fleetLeaderSkills(rules, state, fleet) {
const emp = state.empires[fleet.empireIdx];
const l = emp.leaders.find((x) => x.assignKind === 'fleet' && x.assignId === fleet.id);
return l ? rules.leaders[l.leaderId].skills : {};
}
// --------------------------------------------------------------------------
// Colony derived numbers
export function buildingMult(rules, colony, channel) {
let m = 1;
for (const bid of colony.buildings) {
const b = rules.buildings[bid];
if (b && b.channel === channel) m *= b.mult;
}
return m;
}
function buildingEffect(rules, colony, key) {
let total = 0;
for (const bid of colony.buildings) {
const v = rules.buildings[bid]?.effects?.[key];
if (typeof v === 'number') total += v;
}
return total;
}
function buildingEffectMult(rules, colony, key) {
let m = 1;
for (const bid of colony.buildings) {
const v = rules.buildings[bid]?.effects?.[key];
if (typeof v === 'number') m *= v;
}
return m;
}
export function colonyMaxPop(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
const planet = planetOf(state, colony);
const comps = empireComponents(rules, state, colony.empireIdx);
const skills = colonyLeaderSkills(rules, state, colony);
const base = planet.basePop * spec.traits.maxPopMult;
const bonus = comps.maxPopBonus + buildingEffect(rules, colony, 'maxPopBonus') + (skills.maxPopBonus ?? 0);
const raw = base + bonus;
// Uncleaned industrial waste poisons the biosphere; Lithox never generate any.
const penalty = Math.min(0.75, colony.waste / Math.max(1, planet.basePop * 2));
return Math.max(1, Math.round(raw * (1 - penalty)));
}
export function colonyFactoryCap(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
return Math.round(colony.pop * spec.traits.factoriesPerPop);
}
// Population runs a colony's factories, so a colony that shrinks cannot work
// all of them. The surplus is mothballed rather than demolished — it neither
// produces nor pollutes, and comes back if the population recovers. Without
// this, a colony that loses population keeps generating waste from factories
// nobody is left to staff, which is a death spiral it can never escape.
export function effectiveFactories(rules, state, colony) {
return Math.min(colony.factories, colonyFactoryCap(rules, state, colony));
}
export function colonyProduction(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
const planet = planetOf(state, colony);
const rich = rules.richness[planet.richId]?.industryMult ?? 1;
const skills = colonyLeaderSkills(rules, state, colony);
const eco = rules.economy;
const fromPop = colony.pop * eco.popOutput;
const fromFactories = effectiveFactories(rules, state, colony) * eco.factoryOutput * rich
* spec.traits.industryMult
* buildingMult(rules, colony, 'industry')
* (skills.industryMult ?? 1);
return fromPop + fromFactories;
}
export function colonyDefenseCap(rules, state, colony) {
const comps = empireComponents(rules, state, colony.empireIdx);
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
}
/**
* BC per turn actually reaching the build queue, used for the "N turns" figures
* the colony screen quotes. That is the `ships` share PLUS the two spillovers
* `processColony` folds in: industry whose factories are already capped, and
* defence whose batteries are already topped up.
*
* It has to live here rather than in a view because two different screens quote
* the same ETA and they must agree. It is an estimate by nature — mandatory
* ecology cleanup is taken pro-rata off every channel first, and how much that
* costs depends on waste generated during the turn — so it is deliberately
* quoted before cleanup rather than trying to predict it.
*/
export function colonyBuildRate(rules, state, colony) {
const prod = colonyProduction(rules, state, colony);
let rate = prod * (colony.sliders.ships ?? 0);
if (colony.factories >= colonyFactoryCap(rules, state, colony)) {
rate += prod * (colony.sliders.industry ?? 0);
}
if (colony.defenseHp >= colonyDefenseCap(rules, state, colony)) {
rate += prod * (colony.sliders.defense ?? 0);
}
return Math.max(0, rate);
}
/** Turns for a queue item to finish at the current rate, or Infinity if idle. */
export function queueItemEta(rules, state, colony, item, alreadyQueued = 0) {
const rate = colonyBuildRate(rules, state, colony);
if (rate <= 0) return Infinity;
const remaining = queueItemCost(rules, state, colony, item) - (item.progress ?? 0);
return Math.max(1, Math.ceil((alreadyQueued + remaining) / rate));
}
/**
* Turns until each queue entry finishes, CUMULATIVE down the queue — the engine
* spends on `queue[0]` alone, so an item's ETA includes everything ahead of it.
* Indexed to match `colony.queue`, so a collapsed run reads its figure off its
* own `lastIndex`. Both the colony screen and the system view quote these and
* they have to agree, which is why it is here rather than in either view.
*/
export function queueEtas(rules, state, colony) {
const rate = colonyBuildRate(rules, state, colony);
let acc = 0;
return colony.queue.map((item) => {
acc += Math.max(0, queueItemCost(rules, state, colony, item) - (item.progress ?? 0));
return rate > 0 ? Math.max(1, Math.ceil(acc / rate)) : Infinity;
});
}
export function colonyTrade(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
const skills = colonyLeaderSkills(rules, state, colony);
const eco = rules.economy;
return colony.pop * eco.tradePerPop
* spec.traits.tradeMult
* buildingEffectMult(rules, colony, 'tradeMult')
* (skills.tradeMult ?? 1)
+ buildingEffect(rules, colony, 'tradeBonus');
}
export function colonyGroundDefense(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, colony.empireIdx);
const skills = colonyLeaderSkills(rules, state, colony);
const planet = planetOf(state, colony);
const grav = rules.gravity[planet.gravId];
// High gravity punishes attackers and defenders alike unless you were born
// on a heavy world.
const gravMod = spec.traits.highGravityOk ? 0 : (grav?.combatMod ?? 0);
return (spec.traits.groundDefense ?? 0)
+ (comps.groundDefense ?? 0)
+ buildingEffect(rules, colony, 'groundDefense')
+ (skills.groundDefense ?? 0)
+ gravMod;
}
// Can this empire settle this planet at all?
export function canColonize(rules, state, e, starIdx, orbit) {
if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) return false;
return habitableForEmpire(rules, state, e, starIdx, orbit);
}
// Whether this planet is within an empire's current colonisation reach —
// type + hostility vs. current planetology tech — regardless of whether it is
// already settled. Split out of canColonize so the star map and tooltip's "N
// habitable" readout can match what the colony screen actually offers instead
// of just the planet type's static colonizable flag (which ignores tech and
// made "1 habitable" show even for worlds too hostile to settle yet).
export function habitableForEmpire(rules, state, e, starIdx, orbit) {
const planet = state.galaxy.stars[starIdx]?.planets?.[orbit];
if (!planet) return false;
const type = rules.planetTypes[planet.typeId];
if (!type.colonizable) return false;
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
if (spec.traits.colonizeAnything) return true;
const comps = empireComponents(rules, state, e);
return type.hostility <= comps.colonizeHostility;
}
// How many colonizable worlds this empire has actually SEEN (explored[])
// that nobody has claimed yet — the "Galactic Expansion" Colony Focus's
// build target, and what the advisor recommendations gauge "is the galaxy
// still open" against. Deliberately explored-gated rather than every
// canColonize world in the galaxy (which would recommend expansion into
// worlds the player has no idea exist yet), and deliberately NOT further
// gated by current fuel range like VegaAI.js's own targeting scan — a world
// just past today's range is still "available" once a colony ship reaches
// it, and fuel range only grows with tech.
export function discoveredOpenWorlds(rules, state, e) {
const emp = state.empires[e];
let count = 0;
for (let starIdx = 0; starIdx < state.galaxy.stars.length; starIdx += 1) {
if (!emp.explored[starIdx]) continue;
const star = state.galaxy.stars[starIdx];
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
if (canColonize(rules, state, e, starIdx, orbit)) count += 1;
}
}
return count;
}
// Whether "Galactic Expansion" (Colony Focus) / "Expansion" (Allocation
// Focus) should be the advisors' top pick right now (Brian's ask,
// 2026-08-14): early game, before any rival has been met, land grabs are
// unambiguously the right call — but the instant either condition flips
// (first contact, or every known world already claimed) it drops out of the
// running entirely rather than fading gradually, so the player gets a clean
// "that phase is over" signal instead of a slowly-diminishing nudge.
function expansionFavored(rules, state, e) {
const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
const openTargets = discoveredOpenWorlds(rules, state, e);
return { favored: !hasMetAnyone && openTargets > 0, openTargets };
}
// --------------------------------------------------------------------------
// Range — the star map's "range as light", and the engine's movement rule.
// Every star within fuel range of a colony or star base. Recomputed lazily and
// cached per turn; the AI asks for it constantly.
export function reachableStars(rules, state, e) {
const emp = state.empires[e];
if (emp._rangeAt === state.turn && emp._range) return emp._range;
const comps = empireComponents(rules, state, e);
let range = comps.fuelRange;
const sources = [];
for (const c of empireColonies(state, e)) {
sources.push(c.starIdx);
if (c.buildings.includes('starbase')) range = Math.max(range, comps.fuelRange + (rules.economy.starbaseRangeBonus ?? 3));
}
for (const f of empireFleets(state, e)) {
if (f.starIdx >= 0 && f.ships.some((s) => s.hullId === 'starbase')) sources.push(f.starIdx);
}
const out = {};
for (const src of sources) {
for (let i = 0; i < state.galaxy.stars.length; i += 1) {
if (out[i]) continue;
if (parsecs(state.galaxy, src, i) <= range + 1e-9) out[i] = true;
}
}
emp._range = out;
emp._rangeAt = state.turn;
return out;
}
// --------------------------------------------------------------------------
// Sensors — battlescanner and friends' scanRange tech effect (Brian's ask,
// 2026-08-14). Unlike reachableStars/fuelRange, this isn't cached per turn:
// it is only ever asked for on a fleet click, not in a hot AI loop, so the
// extra cache bookkeeping (and the extra invalidation sites that would need
// it) buys nothing here.
// A fleet's current position in star-map pixel coordinates: its own star's,
// if docked, or interpolated along its lane if in transit — the same lerp
// VegaStarMap.js's refreshFleets uses to DRAW an in-transit marker, just
// reused here (headless, no Phaser) to MEASURE distance to it instead.
function fleetPosition(state, fleet) {
if (fleet.starIdx >= 0) {
const star = state.galaxy.stars[fleet.starIdx];
return star ? { x: star.x, y: star.y } : null;
}
const a = state.galaxy.stars[fleet.fromStar];
const b = state.galaxy.stars[fleet.toStar];
if (!a || !b) return null;
const t = fleet.total > 0 ? Math.min(1, Math.max(0, fleet.progress / fleet.total)) : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// Every point this empire could currently be detecting FROM: its colonies
// and its own fleets (docked or in transit), unlike reachableStars' sources
// (colonies + starbases only) — a sensor reach is about where your hulls
// actually ARE right now, not where you could send something from.
function scanSources(state, e) {
const sources = [];
for (const c of empireColonies(state, e)) {
const star = state.galaxy.stars[c.starIdx];
if (star) sources.push({ x: star.x, y: star.y });
}
for (const f of empireFleets(state, e)) {
const p = fleetPosition(state, f);
if (p) sources.push(p);
}
return sources;
}
/**
* Whether `viewerE` can currently detect `fleet` (any empire's, including
* its own — always true then via a zero-distance source) on sensors: within
* scanRange parsecs of one of the viewer's own colonies or fleets. Zero
* scanRange (no battlescanner-line tech yet) means nothing is ever
* detected — this is what turns "reveals enemy fleet composition" from
* flavor text into a real mechanic (see VegaTechEffects.js's scanRange
* formatter, and MasterOfVegaGame.js's onFleetClick / VegaSidePanel.js's
* buildFleet for what unlocks behind it).
*/
export function fleetInScanRange(rules, state, viewerE, fleet) {
const range = empireComponents(rules, state, viewerE).scanRange;
if (range <= 0) return false;
const pos = fleetPosition(state, fleet);
if (!pos) return false;
return scanSources(state, viewerE).some(
(s) => Math.hypot(s.x - pos.x, s.y - pos.y) / PARSEC_PX <= range + 1e-9,
);
}
export function fleetSpeed(rules, state, fleet) {
const emp = state.empires[fleet.empireIdx];
const spec = rules.species[emp.speciesId];
const skills = fleetLeaderSkills(rules, state, fleet);
// Immobile hulls (star bases) are SKIPPED, not disqualifying.
//
// Returning 0 for any fleet containing a star base looked reasonable and was
// catastrophic: a completed Star Base joins the fleet sitting over its own
// colony, that fleet is the empire's main battle fleet, and from that moment
// canSendFleet refused every order it was ever given. Measured over one game,
// 338 of 338 valid attacks — in range, strong enough, at war — were refused
// for this reason alone. No colony could ever be attacked, so no war could be
// won and conquest was unreachable. sendFleet() leaves the bases behind.
let speed = Infinity;
let mobile = 0;
for (const s of fleet.ships) {
if (s.count <= 0) continue;
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null);
if (d.immobile) continue;
mobile += 1;
speed = Math.min(speed, d.speed);
}
if (!mobile) return 0;
return Number.isFinite(speed) ? Math.max(1, speed) : 0;
}
export function fleetPower(rules, state, fleet) {
const emp = state.empires[fleet.empireIdx];
const spec = rules.species[emp.speciesId];
const skills = fleetLeaderSkills(rules, state, fleet);
let p = 0;
for (const s of fleet.ships) {
if (s.count <= 0) continue;
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null);
if (d.role !== 'warship' && d.role !== 'base') continue;
p += s.count * (d.hp + d.damage * 4);
}
return Math.round(p);
}
// --------------------------------------------------------------------------
// Game creation
// MOO1's actual rule, corrected: racial skill (techAffinity, via
// techCostFactor) changes what a tech COSTS, never whether it exists in your
// tree. Availability is a flat roll per tech — 50% for every species, 75%
// for Cerebrai, the one species built around research breadth (uniformly
// >=1.2 techAffinity across every field, researchMult 2 — our closest
// analogue to Psilons). The one guarantee is that a rung of 2+ alternatives
// can never end up with zero available techs; a size-1 rung (most of the
// tree today) has nothing to fall back on, so its lone tech can still
// legitimately miss its roll and simply be skipped (rungCleared) — that is
// what makes tech trading matter for the majority of the tree, not just the
// branched rungs.
const PSILON_ANALOGUE = 'cerebrai';
export function rollTechAvailability(state, rules, emp, spec) {
const available = {};
const chance = spec.id === PSILON_ANALOGUE ? 0.75 : 0.5;
for (const t of rules.techList) {
available[t.id] = t.tier === 0 ? true : rand(state) < chance;
}
for (const field of Object.keys(rules.techFields)) {
for (const rung of rules.techRungsByField[field]) {
if (rung.techs.length <= 1) continue;
if (rung.techs.every((t) => !available[t.id])) {
available[rung.techs[randInt(state, rung.techs.length)].id] = true;
}
}
}
return available;
}
export function createGame(rules, opts) {
const {
sizeId = 'medium', shapeId = 'spiral', seed = 1, difficultyId = 'normal',
speciesIds = ['human', 'kkrix', 'rrashaa'], humanIndex = 0, homeColonyName = null,
} = opts;
const state = {
version: 1,
rules: null,
seed,
rngState: (seed * 2654435761) | 0,
sizeId, shapeId, difficultyId,
turn: 0,
current: 0,
humanIndex,
galaxy: null,
empires: [],
colonies: [],
fleets: [],
nextColonyId: 0,
nextFleetId: 0,
nextEventSeq: 0,
events: [],
council: {
nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false,
},
gnn: { history: [] },
over: false,
winnerIdx: -1,
victoryKind: null,
};
state.galaxy = generateGalaxy(rules, { sizeId, shapeId, seed, speciesIds });
const diff = rules.difficulties[difficultyId];
speciesIds.forEach((sid, e) => {
const spec = rules.species[sid];
const emp = {
idx: e,
speciesId: sid,
name: spec.name,
color: spec.color,
alive: true,
isHuman: e === humanIndex,
homeStar: state.galaxy.homeIdx[e],
known: {},
available: {},
techsKnown: 0,
knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 },
researching: {},
alloc: {},
allocLocked: {},
beakers: {},
bc: rules.economy.startingBC,
leaders: [],
contacted: {},
treaties: {},
attitude: {},
pendingOffers: {},
tradeAgreements: {},
lastGiftTurn: {},
// colony.id -> the turn this empire last bombarded it. Caps bombard()
// at once per (attacker, colony) per turn — see bombard()'s own
// comment for why: unlike invade() it spends no resource of its own
// (warships aren't consumed), so without this cap the UI's Bombard
// button could be clicked any number of times in one turn.
lastBombardTurn: {},
fleetIntrusions: {},
// enemyIdx -> { losses }, a consecutive-attack-loss streak read by
// VegaAI.js's manageFleets to demand progressively more force before
// trying that enemy again rather than attacking at the same losing
// fleet size on repeat. warMemorySeq is the highest event `seq`
// (pushEvent, above) already scanned for 'combat' events (VegaAI.js's
// updateWarMemory) — plain fields, not underscore-prefixed caches,
// since unlike _comps/_range they aren't recomputable from current
// tech and have to survive save/load.
warMemory: {},
warMemorySeq: 0,
explored: {},
spyPoints: 0,
// otherIdx -> 'steal' | 'sabotage', missing entry means 'steal' (see
// runEspionage) — every empire behaves exactly as it always has until
// the human explicitly sets a mission on the Audience screen.
espionageMission: {},
totalPop: 0,
nameCursor: 0,
// A per-empire shuffle of its species' colonyNames indices, so
// peekColonyName() hands out that bank in a random (but replay-stable)
// order instead of always suggesting the same name first. Drawn from a
// seed derived from the game seed and empire index rather than
// state.rngState — this is cosmetic, not gameplay, and keeping it off
// the shared RNG stream means it can never nudge tech rolls, combat, or
// anything else a replay depends on.
nameOrder: shuffledIndexes(mulberry32((seed + (e + 1) * 7919) >>> 0), spec.colonyNames.length),
_comps: null, _compsAt: -1, _range: null, _rangeAt: -1, _designs: null, _designsAt: -1,
};
// Research starts spread evenly across the six fields.
const fields = Object.keys(rules.techFields);
for (const f of fields) { emp.alloc[f] = 1 / fields.length; emp.beakers[f] = 0; emp.researching[f] = null; }
emp.available = rollTechAvailability(state, rules, emp, spec);
state.empires.push(emp);
});
// Mutual ignorance to start; attitudes are neutral except where a species is
// simply disliked on sight.
for (const a of state.empires) {
for (const b of state.empires) {
if (a.idx === b.idx) continue;
a.treaties[b.idx] = 'none';
const spec = rules.species[a.speciesId];
a.attitude[b.idx] = Math.round((spec.traits.diplomacy ?? 0) / 4);
}
}
// Homeworlds.
speciesIds.forEach((sid, e) => {
const starIdx = state.galaxy.homeIdx[e];
const name = e === humanIndex ? homeColonyName : null;
const colony = foundColony(rules, state, e, starIdx, 0, rules.economy.startingPop, name);
colony.factories = rules.economy.startingFactories;
colony.capital = true;
state.empires[e].explored[starIdx] = true;
checkContactAt(rules, state, starIdx);
// Everything else starts unexplored — a scout has to physically arrive
// (see moveFleets' `explored[f.starIdx] = true`) before its details show.
// Starting fleet: a scout and a colony ship, plus difficulty handicap ships.
const bonus = e === humanIndex ? 0 : diff.aiStartBonus;
addFleet(rules, state, e, starIdx, [
{ hullId: 'scout', mark: 1, count: 1 + bonus },
{ hullId: 'colonyship', mark: 1, count: 1 },
...(bonus > 0 ? [{ hullId: 'frigate', mark: 1, count: bonus * 2 }] : []),
]);
});
recomputeTotals(rules, state);
return state;
}
function shuffledIndexes(rnd, len) {
const arr = Array.from({ length: len }, (_, i) => i);
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(rnd() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Read-only preview of the name a new colony for this empire would get,
// without consuming it from its species' pool — lets UI show a default
// before the player confirms. Walks the species' 50-name bank in this
// empire's own shuffled order (`nameOrder`, set once in createGame), so
// suggestions are randomized but a cursor that only ever moves forward still
// guarantees no name already in use by this empire is suggested again. A
// round past the end (which the bank is generous enough to make rare) appends
// a Roman-numeral suffix rather than repeating a bare name.
export function peekColonyName(rules, state, e) {
const emp = state.empires[e];
const pool = rules.species[emp.speciesId].colonyNames;
const cursor = emp.nameCursor ?? 0;
// Saves from before per-empire shuffling existed have no nameOrder (or one
// sized for an older, shorter bank) — fall back to bank order rather than
// crashing.
const order = (emp.nameOrder && emp.nameOrder.length === pool.length)
? emp.nameOrder : pool.map((_, i) => i);
const round = Math.floor(cursor / pool.length);
const name = pool[order[cursor % pool.length]];
return round > 0 ? `${name} ${'I'.repeat(round + 1)}` : name;
}
function advanceColonyNameCursor(state, e) {
state.empires[e].nameCursor = (state.empires[e].nameCursor ?? 0) + 1;
}
export function nextColonyName(rules, state, e) {
const name = peekColonyName(rules, state, e);
advanceColonyNameCursor(state, e);
return name;
}
export function foundColony(rules, state, e, starIdx, orbit, pop, name = null) {
const colonyName = (name && name.trim()) ? name.trim() : peekColonyName(rules, state, e);
advanceColonyNameCursor(state, e);
const colony = {
id: state.nextColonyId += 1,
empireIdx: e,
starIdx,
orbit,
pop,
name: colonyName,
factories: 0,
waste: 0,
defenseHp: 0,
buildings: [],
queue: [],
capital: false,
sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 },
locked: {},
focus: 'manual', // 'manual' | 'improvement' | 'research' | 'fleet' | 'growth' | 'trade' | 'defense'
// Advisor re-analysis tracking (checkAdvisorRecommendations). The Quiet*
// fields are null until the player first picks something in the
// corresponding flyout — advisors never nag a colony that has not been
// touched. allocKey is tracking-only: it does not lock the sliders.
advisor: {
focusQuietUntil: null, focusNotifiedValue: null,
allocQuietUntil: null, allocKey: null, allocNotifiedKey: null,
},
founded: state.turn,
};
state.colonies.push(colony);
state.empires[e].explored[starIdx] = true;
return colony;
}
export function addFleet(rules, state, e, starIdx, ships) {
// Merge into an existing fleet at the same star rather than littering the map
// with one-ship fleets.
const existing = state.fleets.find((f) => f.empireIdx === e && f.starIdx === starIdx && f.toStar < 0);
if (existing) {
for (const s of ships) {
const m = existing.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
if (m) m.count += s.count;
else existing.ships.push({ ...s });
}
return existing;
}
const fleet = {
id: state.nextFleetId += 1,
empireIdx: e,
starIdx,
fromStar: -1,
toStar: -1,
progress: 0,
total: 0,
ships: ships.map((s) => ({ ...s })),
};
state.fleets.push(fleet);
return fleet;
}
function cleanFleets(state) {
for (const f of state.fleets) f.ships = f.ships.filter((s) => s.count > 0);
state.fleets = state.fleets.filter((f) => f.ships.length > 0);
}
// Merge every idle fleet an empire has sitting in the same system. Ships
// completing while the local fleet happens to be in transit each spawn a new
// fleet, and over a long game that compounds into hundreds of one-ship stacks —
// which is both unreadable on the star map and quadratic work for the AI.
function consolidateFleets(rules, state, e) {
const byStar = new Map();
for (const f of state.fleets) {
if (f.empireIdx !== e || f.starIdx < 0 || f.toStar >= 0) continue;
// Key on mobility as well as location, so a star-base garrison never gets
// folded back into the battle fleet it was just split out of.
const mobile = fleetSpeed(rules, state, f) > 0;
const key = `${f.starIdx}|${mobile ? 'm' : 'g'}`;
const head = byStar.get(key);
if (!head) { byStar.set(key, f); continue; }
for (const s of f.ships) {
const m = head.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
if (m) m.count += s.count;
else head.ships.push({ ...s });
}
f.ships = [];
}
cleanFleets(state);
}
// --------------------------------------------------------------------------
// Research
// A rung (tech.field + tech.tier) clears once ANY one of its techs is known
// — MOO1-style: pick one alternative to advance, the rest stay legitimate
// side-picks rather than being blocked or required. A rung with zero
// available techs (every option missed its species roll) is also cleared —
// there's nothing left to wait on, so research simply moves past it.
function rungCleared(emp, rung) {
return rung.techs.some((t) => emp.known[t.id]) || rung.techs.every((t) => !emp.available[t.id]);
}
export function canResearch(rules, state, e, tech) {
const emp = state.empires[e];
if (emp.known[tech.id] || !emp.available[tech.id]) return false;
// Every lower rung in the field must be cleared.
for (const rung of rules.techRungsByField[tech.field]) {
if (rung.tier >= tech.tier) break;
if (!rungCleared(emp, rung)) return false;
}
return true;
}
export function nextResearchTarget(rules, state, e, field) {
for (const t of rules.techsByField[field]) {
if (canResearch(rules, state, e, t)) return t.id;
}
return null;
}
// The techs at a field's current frontier rung that are actually
// researchable right now. Length 0 = field exhausted; length 1 = the
// unambiguous pick nextResearchTarget already made; length 2+ = a live
// choice the player hasn't resolved — this is what VegaResearchChoiceScreen
// prompts for.
export function openResearchChoices(rules, state, e, field) {
const candidates = rules.techsByField[field].filter((t) => canResearch(rules, state, e, t));
if (!candidates.length) return [];
const minTier = Math.min(...candidates.map((t) => t.tier));
return candidates.filter((t) => t.tier === minTier);
}
// An original formula, not a MOO1 port — ×5 per known tier echoes the real
// MOO1 fact that its 10 sub-fields each span 5 levels; +1 per extra known
// tech below the frontier echoes the general shape (not the literals) of
// MOO1's own scoring. Pure function of emp.known/rules.techsByField.
export function fieldTechLevel(rules, state, e, field) {
const emp = state.empires[e];
const knownTiers = rules.techsByField[field]
.filter((t) => emp.known[t.id])
.map((t) => t.tier);
if (knownTiers.length === 0) return 0;
const highestKnownTier = Math.max(...knownTiers);
const extraKnownBelowFrontier = knownTiers.filter((t) => t < highestKnownTier).length;
return highestKnownTier * 5 + extraKnownBelowFrontier;
}
// Player (or a future AI override) picks a specific tech within its field's
// currently-open rung. Deliberately does not touch emp.beakers[field] — RP
// banked in a field is a shared pool (see processResearch below), so
// switching which tech it's funding never loses progress, matching MOO1.
export function setResearchTarget(rules, state, e, field, techId) {
const emp = state.empires[e];
const tech = rules.techs[techId];
if (!tech || tech.field !== field || !canResearch(rules, state, e, tech)) return false;
emp.researching[field] = techId;
return true;
}
export function grantTech(rules, state, e, techId, source = 'research') {
const emp = state.empires[e];
if (emp.known[techId]) return false;
const tech = rules.techs[techId];
emp.known[techId] = true;
emp.available[techId] = true;
emp.techsKnown += 1;
emp.knownInField[tech.field] += 1;
emp._comps = null;
emp._compsAt = -1;
emp._range = null;
emp._rangeAt = -1;
emp._designs = null;
emp._designsAt = -1;
pushEvent(state, { type: 'techDone', empire: e, techId, source, turn: state.turn });
return true;
}
function processResearch(rules, state, e, beakers) {
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
for (const field of Object.keys(rules.techFields)) {
if (!emp.researching[field]) emp.researching[field] = nextResearchTarget(rules, state, e, field);
const targetId = emp.researching[field];
if (!targetId) continue;
emp.beakers[field] += beakers * (emp.alloc[field] ?? 0);
const tech = rules.techs[targetId];
const cost = techCost(rules, tech, emp.knownInField[field], techCostFactor(spec, field));
if (emp.beakers[field] >= cost) {
emp.beakers[field] -= cost;
grantTech(rules, state, e, targetId);
emp.researching[field] = nextResearchTarget(rules, state, e, field);
}
}
}
// --------------------------------------------------------------------------
// Colony turn
// Allocation with MOO1 spillover: a channel that cannot use its share passes it
// on rather than burning it. Industry that has maxed its factories feeds the
// build queue; a clean colony's ecology money becomes research. Without this,
// a developed colony quietly wastes most of its output.
function processColony(rules, state, colony) {
const e = colony.empireIdx;
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
const skills = colonyLeaderSkills(rules, state, colony);
const eco = rules.economy;
const prod = colonyProduction(rules, state, colony);
const share = {};
for (const ch of CHANNELS) share[ch] = prod * (colony.sliders[ch] ?? 0);
// --- Ecology: clean this turn's waste plus any backlog.
//
// Cleanup is MANDATORY. If the ecology slider does not cover it, the
// shortfall is taken pro-rata out of the other four channels — exactly the
// way MOO1's eco slider snaps up to the minimum on its own. Letting a colony
// simply not pay is a death spiral with no exit: unpaid waste cuts maximum
// population, the smaller population mothballs factories and produces less,
// which leaves even less to spend on the waste that is still there.
const wasteGen = effectiveFactories(rules, state, colony) * eco.wastePerFactory * spec.traits.ecologyMult;
const needUnits = colony.waste + wasteGen;
const costPerUnit = eco.wasteCleanupCost * comps.wasteMult
* buildingMult(rules, colony, 'ecology') * (skills.ecologyMult ?? 1);
let ecoSpill = 0;
if (needUnits <= 0 || costPerUnit <= 0) {
colony.waste = 0;
ecoSpill = share.ecology;
} else {
const cleanupCost = needUnits * costPerUnit;
if (share.ecology >= cleanupCost) {
colony.waste = 0;
ecoSpill = share.ecology - cleanupCost;
} else {
let shortfall = cleanupCost - share.ecology;
const donors = ['industry', 'ships', 'defense', 'research'];
const pool = donors.reduce((t, ch) => t + share[ch], 0);
if (pool > 0) {
const take = Math.min(shortfall, pool);
for (const ch of donors) {
share[ch] -= take * (share[ch] / pool);
}
shortfall -= take;
}
const paid = cleanupCost - shortfall;
colony.waste = Math.max(0, needUnits - paid / costPerUnit);
colony.ecoForced = Math.round(paid - share.ecology > 0 ? paid - Math.min(paid, prod * (colony.sliders.ecology ?? 0)) : 0);
}
}
// --- Industry: factories, capped by population.
const factoryCost = eco.factoryCost * comps.factoryCostMult;
const capF = colonyFactoryCap(rules, state, colony);
let indSpill = 0;
if (colony.factories >= capF) {
indSpill = share.industry;
} else {
const built = Math.min(capF - colony.factories, share.industry / factoryCost);
colony.factories += built;
indSpill = Math.max(0, share.industry - built * factoryCost);
}
// --- Defence: planetary batteries, capped by tech.
const capD = colonyDefenseCap(rules, state, colony);
let defSpill = 0;
if (colony.defenseHp >= capD) {
defSpill = share.defense;
} else {
const added = Math.min(capD - colony.defenseHp, share.defense);
colony.defenseHp += added;
defSpill = Math.max(0, share.defense - added);
}
// --- Construction: the build queue (ships and buildings).
let build = share.ships + indSpill + defSpill;
let buildSpill = 0;
let guard = 0;
while (build > 0 && colony.queue.length > 0 && guard < 20) {
guard += 1;
const item = colony.queue[0];
const cost = queueItemCost(rules, state, colony, item);
const need = cost - item.progress;
if (build >= need) {
build -= need;
completeQueueItem(rules, state, colony, item);
colony.queue.shift();
} else {
item.progress += build;
build = 0;
}
}
if (colony.queue.length === 0) buildSpill = build;
// --- Research absorbs everything left over.
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
// --- Population.
const maxPop = colonyMaxPop(rules, state, colony);
const growth = colony.pop * eco.growthRateBase
* (1 - colony.pop / Math.max(1, maxPop))
* spec.traits.growthMult
* buildingEffectMult(rules, colony, 'growthMult')
* (skills.growthMult ?? 1);
colony.pop = Math.max(0.5, Math.min(maxPop, colony.pop + growth));
return { research, trade: colonyTrade(rules, state, colony) };
}
export function queueItemCost(rules, state, colony, item) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
if (item.kind === 'building') return rules.buildings[item.id].cost;
return empireDesign(rules, state, colony.empireIdx, item.id).cost;
}
function completeQueueItem(rules, state, colony, item) {
const e = colony.empireIdx;
if (item.kind === 'building') {
if (!colony.buildings.includes(item.id)) colony.buildings.push(item.id);
pushEvent(state, { type: 'buildingDone', empire: e, colonyId: colony.id, buildingId: item.id, starIdx: colony.starIdx, turn: state.turn });
return;
}
const emp = state.empires[e];
const mark = markFor(rules, emp.known);
if (item.id === 'starbase') {
if (!colony.buildings.includes('starbase')) colony.buildings.push('starbase');
emp._range = null; emp._rangeAt = -1;
}
addFleet(rules, state, e, colony.starIdx, [{ hullId: item.id, mark, count: 1 }]);
pushEvent(state, { type: 'shipDone', empire: e, colonyId: colony.id, hullId: item.id, starIdx: colony.starIdx, turn: state.turn });
}
// --------------------------------------------------------------------------
// Empire turn
export function beginEmpireTurn(rules, state, e) {
const emp = state.empires[e];
if (!emp.alive) return;
const spec = rules.species[emp.speciesId];
const diff = rules.difficulties[state.difficultyId];
const comps = empireComponents(rules, state, e);
let research = 0;
let trade = 0;
let upkeep = 0;
for (const colony of empireColonies(state, e)) {
const r = processColony(rules, state, colony);
research += r.research;
trade += r.trade;
for (const bid of colony.buildings) upkeep += rules.buildings[bid]?.upkeep ?? 0;
}
// Fleets cost a fraction of their build cost every turn.
for (const f of empireFleets(state, e)) {
for (const s of f.ships) {
upkeep += empireDesign(rules, state, e, s.hullId).cost
* (rules.economy.shipUpkeepFraction ?? 0.02) * s.count;
}
}
for (const l of emp.leaders) upkeep += rules.leaders[l.leaderId].upkeep;
// Active trade agreements add a modest ongoing income, scaled to the
// smaller of the two economies so a deal with a tiny neighbor is never a
// windfall.
let tradeAgreementIncome = 0;
for (const otherIdx of Object.keys(emp.tradeAgreements)) {
const other = state.empires[otherIdx];
if (!other?.alive) continue;
tradeAgreementIncome += rules.diplomacy.tradeAgreement.bcRatePerPop
* Math.min(emp.totalPop, other.totalPop);
}
emp.bc = Math.max(0, emp.bc + trade + tradeAgreementIncome - upkeep);
emp.lastIncome = trade + tradeAgreementIncome - upkeep;
const researchMult = spec.traits.researchMult
* comps.researchMult
* (emp.isHuman ? diff.humanResearchMult : diff.aiResearchMult);
processResearch(rules, state, e, research * researchMult);
consolidateFleets(rules, state, e);
autoRefit(rules, state, e);
runEspionage(rules, state, e);
recomputeTotals(rules, state);
}
// Ships sitting over a friendly colony are brought up to the current Mark, paid
// for out of the reserve. This is the whole reason preset hulls still feel
// connected to the tech tree: research a better gun and your existing fleet
// visibly improves, with a report line to say so.
function autoRefit(rules, state, e) {
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const mark = markFor(rules, emp.known);
for (const f of empireFleets(state, e)) {
if (f.starIdx < 0 || f.toStar >= 0) continue;
const colony = state.colonies.find((c) => c.starIdx === f.starIdx && c.empireIdx === e);
if (!colony) continue;
for (const s of f.ships) {
if (s.mark >= mark) continue;
const cost = refitCost(rules, emp.known, s.hullId, s.mark, spec.traits) * s.count;
if (emp.bc < cost) continue;
emp.bc -= cost;
const from = s.mark;
s.mark = mark;
pushEvent(state, {
type: 'refit', empire: e, starIdx: f.starIdx, hullId: s.hullId,
count: s.count, fromMark: from, toMark: mark, cost: Math.round(cost), turn: state.turn,
});
}
}
}
// Espionage is passive: a species with spies gets periodic attempts against
// empires it has met, and either steals a tech it could not research itself
// or sabotages a colony — whichever mission the attacker has standing (set
// from the Audience screen's Intelligence toggle, default 'steal').
function runEspionage(rules, state, e) {
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
let power = (spec.traits.espionage ?? 0) + comps.espionage;
for (const c of empireColonies(state, e)) {
power += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).espionage ?? 0);
}
if (power <= 0) return;
// Only the surplus over a baseline counts. A Battle-Scanner-and-nothing-else
// empire is not a spy agency: crediting its raw score let EVERY empire steal
// its way to the full tech tree over a long game, which made the per-species
// availability roll — the whole reason tech trading exists — meaningless.
const net = power - 25;
if (net <= 0) return;
emp.spyPoints += net / 300;
if (emp.spyPoints < 1) return;
emp.spyPoints -= 1;
// 'off' (set from the Audience screen's Intelligence toggle) pulls that
// empire out of the random-target pool entirely — the only way, since
// targeting is otherwise a single shared roll across everyone contacted,
// to actually stop spying on someone rather than just changing what a hit
// against them does.
const targets = state.empires.filter((o) => o.alive && o.idx !== e
&& emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance'
&& (emp.espionageMission[o.idx] ?? 'steal') !== 'off');
if (!targets.length) return;
const target = targets[randInt(state, targets.length)];
const mission = emp.espionageMission[target.idx] ?? 'steal';
const tspec = rules.species[target.speciesId];
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
for (const c of empireColonies(state, target.idx)) {
defence += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).counterEspionage ?? 0);
}
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
if (rand(state) > odds) {
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, mission, turn: state.turn });
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 12));
return;
}
// A SUCCESSFUL mission carries no attitude penalty (Brian's ask) — nobody
// publicly knows who did it (see VegaGnn.js's `attributed` flag), so there
// is nobody for the victim to be angry AT. Getting CAUGHT above is the one
// espionage outcome that still costs relations, since that one names names.
if (mission === 'sabotage') {
const targetColonies = empireColonies(state, target.idx);
if (!targetColonies.length) return;
const colony = targetColonies[randInt(state, targetColonies.length)];
const cfg = rules.diplomacy.espionage;
const factoriesLost = Math.round(colony.factories * cfg.sabotageFactoriesFraction);
const defenseLost = Math.round(colony.defenseHp * cfg.sabotageDefenseFraction);
colony.factories = Math.max(0, colony.factories - factoriesLost);
colony.defenseHp = Math.max(0, colony.defenseHp - defenseLost);
pushEvent(state, {
type: 'sabotage', empire: e, target: target.idx, starIdx: colony.starIdx,
factoriesLost, defenseLost, turn: state.turn,
});
return;
}
const stealable = rules.techList.filter((t) => target.known[t.id] && !emp.known[t.id]);
if (!stealable.length) return;
const tech = stealable[randInt(state, stealable.length)];
grantTech(rules, state, e, tech.id, 'espionage');
pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn });
}
function recomputeTotals(rules, state) {
for (const emp of state.empires) emp.totalPop = 0;
for (const c of state.colonies) state.empires[c.empireIdx].totalPop += c.pop;
}
// --------------------------------------------------------------------------
// Movement, arrival, combat
export function canSendFleet(rules, state, fleet, toStar) {
// A DOCKED fleet just needs a real destination different from where it
// sits. An IN-FLIGHT fleet additionally needs Hyperspace Communications'
// redirectInFlight effect — without it, an order already under way is
// locked in, same as always — and a destination other than the one it is
// already headed for (retargeting to that is a no-op, same as ordering a
// docked fleet to stay where it is).
if (fleet.toStar >= 0) {
if (!empireComponents(rules, state, fleet.empireIdx).redirectInFlight) return false;
if (toStar === fleet.toStar) return false;
} else if (fleet.starIdx === toStar) {
return false;
}
if (fleetSpeed(rules, state, fleet) <= 0) return false;
const reach = reachableStars(rules, state, fleet.empireIdx);
return !!reach[toStar];
}
export function sendFleet(rules, state, fleet, toStar) {
if (!canSendFleet(rules, state, fleet, toStar)) return false;
if (fleet.toStar >= 0) {
// Redirecting a fleet already under way (Hyperspace Communications,
// gated in canSendFleet above): no garrison to split off — an immobile
// hull can never have left port in the first place — so this is just a
// fresh leg from its ORIGINAL destination, the one real star anchor
// still available mid-flight. Matches the same fromStar-becomes-toStar
// convention etaTo() already uses to estimate an in-flight fleet's ETA
// to some OTHER star, so a quoted order's preview (VegaSidePanel.js's
// buildOrder, which calls etaTo()) lines up with what actually happens
// here on Accept.
fleet.fromStar = fleet.toStar;
fleet.toStar = toStar;
fleet.total = parsecs(state.galaxy, fleet.fromStar, toStar);
fleet.progress = 0;
return true;
}
// Star bases defend the system they were built in and do not sail with the
// fleet; split them into a garrison that stays put.
const garrison = [];
fleet.ships = fleet.ships.filter((s) => {
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId);
if (!d.immobile) return true;
garrison.push(s);
return false;
});
if (garrison.length) {
state.fleets.push({
id: state.nextFleetId += 1,
empireIdx: fleet.empireIdx,
starIdx: fleet.starIdx,
fromStar: -1, toStar: -1, progress: 0, total: 0,
ships: garrison,
});
}
fleet.fromStar = fleet.starIdx;
fleet.toStar = toStar;
fleet.total = parsecs(state.galaxy, fleet.starIdx, toStar);
fleet.progress = 0;
fleet.starIdx = -1;
return true;
}
export function fleetEta(rules, state, fleet) {
if (fleet.toStar < 0) return 0;
const speed = fleetSpeed(rules, state, fleet);
if (speed <= 0) return Infinity;
return Math.max(1, Math.ceil((fleet.total - fleet.progress) / speed));
}
// How long an order WOULD take, asked before it is given — the number the star
// map's order panel shows next to Accept. `ships` scopes it to a detachment the
// player has selected, whose speed is the slowest hull in the selection rather
// than the slowest in the whole fleet: leaving the colony ships at home is how
// you make a raid arrive this decade, so the ETA has to react to the selection.
export function etaTo(rules, state, fleet, toStar, ships = null) {
const from = fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar;
if (from < 0 || from === toStar) return 0;
const speed = fleetSpeed(rules, state, ships ? { ...fleet, ships } : fleet);
if (speed <= 0) return Infinity;
return Math.max(1, Math.ceil(parsecs(state.galaxy, from, toStar) / speed));
}
// Fold a [{hullId, mark, count}] request into one entry per stack, so asking
// for the same stack twice cannot slip past the "do you have this many?" check.
function normaliseTake(take) {
const out = new Map();
for (const t of take ?? []) {
const n = Math.floor(t.count ?? 0);
if (n <= 0) continue;
const key = `${t.hullId}|${t.mark}`;
const prev = out.get(key);
if (prev) prev.count += n;
else out.set(key, { hullId: t.hullId, mark: t.mark, count: n });
}
return [...out.values()];
}
const takeTotal = (take) => take.reduce((t, s) => t + s.count, 0);
const fleetTotal = (fleet) => fleet.ships.reduce((t, s) => t + Math.max(0, s.count), 0);
/**
* Detach part of an idle fleet into a fleet of its own, parked at the same star.
* Returns the new fleet, or null if the request was not satisfiable.
*
* Two idle fleets in one system are merged again by consolidateFleets at the
* start of the owner's next turn, which is MOO1's rule and deliberate — so the
* only splitting the UI offers is "send some of these ships somewhere"
* (sendDetachment below), where the detachment leaves the same instant.
*
* The detachment gets a fresh fleet id and therefore does NOT inherit the
* parent's fleet leader; the leader stays with the fleet they were posted to.
*/
export function splitFleet(rules, state, fleet, take) {
if (!fleet || fleet.starIdx < 0 || fleet.toStar >= 0) return null;
const wanted = normaliseTake(take);
const taken = takeTotal(wanted);
if (taken <= 0 || taken >= fleetTotal(fleet)) return null;
// Verify the whole request before mutating anything, or a request that is
// half-satisfiable leaves the fleet carved up and the order refused.
const pairs = [];
for (const t of wanted) {
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
if (!src || src.count < t.count) return null;
pairs.push([src, t.count]);
}
const ships = [];
for (const [src, n] of pairs) {
src.count -= n;
ships.push({ ...src, count: n });
}
fleet.ships = fleet.ships.filter((s) => s.count > 0);
const detachment = {
id: state.nextFleetId += 1,
empireIdx: fleet.empireIdx,
starIdx: fleet.starIdx,
fromStar: -1, toStar: -1, progress: 0, total: 0,
ships,
};
state.fleets.push(detachment);
return detachment;
}
/**
* Send some of a fleet's ships to a star: the selection departs, the rest stay
* behind. Selecting everything is just sendFleet. Returns the fleet that is now
* under way, or null if the order was refused.
*/
export function sendDetachment(rules, state, fleet, toStar, take) {
if (!fleet) return null;
const wanted = normaliseTake(take);
const taken = takeTotal(wanted);
if (taken <= 0) return null;
// Check the request against the actual stacks BEFORE comparing totals, or
// asking for nine of a stack of three reads as "all of them" and quietly
// sends the whole fleet instead of refusing.
for (const t of wanted) {
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
if (!src || src.count < t.count) return null;
}
if (taken === fleetTotal(fleet)) {
return sendFleet(rules, state, fleet, toStar) ? fleet : null;
}
// Ask whether the DETACHMENT could fly before splitting it out, so a refused
// order never leaves the fleet in pieces.
const probe = { ...fleet, ships: wanted.map((t) => ({ ...t })) };
if (!canSendFleet(rules, state, probe, toStar)) return null;
const detachment = splitFleet(rules, state, fleet, wanted);
if (!detachment) return null;
if (!sendFleet(rules, state, detachment, toStar)) {
// Unreachable given the probe, but a half-split fleet would be a silent
// loss of ships, so put them back rather than trust the reasoning.
for (const s of detachment.ships) {
const m = fleet.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
if (m) m.count += s.count;
else fleet.ships.push({ ...s });
}
state.fleets = state.fleets.filter((f) => f !== detachment);
return null;
}
return detachment;
}
function moveFleets(rules, state, e) {
for (const f of empireFleets(state, e)) {
if (f.toStar < 0) continue;
f.progress += fleetSpeed(rules, state, f);
if (f.progress >= f.total - 1e-9) {
f.starIdx = f.toStar;
f.toStar = -1;
f.fromStar = -1;
f.progress = 0;
f.total = 0;
const wasExplored = !!state.empires[e].explored[f.starIdx];
state.empires[e].explored[f.starIdx] = true;
pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn });
if (!wasExplored) {
pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn });
}
checkContactAt(rules, state, f.starIdx);
deliverPopulation(rules, state, f);
}
}
cleanFleets(state);
}
// Empires meet by sharing a system — a ship or colony of one sitting where a
// ship or colony of another already is, for the first time. Contact used to
// be proximity-based (any two empires' colonies within N parsecs), which met
// everyone quietly and gave the human no sense that first contact was an
// event. Star-scoped and all-pairs so it is agnostic to *why* two empires
// ended up sharing a star (a fleet arriving, a colony founded onto an
// already-occupied system) — every caller just says "check this star."
export function checkContactAt(rules, state, starIdx) {
const present = new Set();
for (const c of coloniesAt(state, starIdx)) present.add(c.empireIdx);
for (const f of fleetsAt(state, starIdx)) present.add(f.empireIdx);
const list = [...present];
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
const a = list[i];
const b = list[j];
if (state.empires[a].contacted[b]) continue;
state.empires[a].contacted[b] = true;
state.empires[b].contacted[a] = true;
pushEvent(state, { type: 'contact', empire: a, other: b, starIdx, turn: state.turn });
}
}
}
export function atWar(state, a, b) {
return state.empires[a]?.treaties?.[b] === 'war';
}
// Resolve every star where hostile forces now share orbit. Called once per
// empire turn after movement, so a fleet that arrives is engaged immediately.
//
// `excludeEmpire`, if given, skips any pairing that empire is a party to
// (attacker, defender, OR undefended-colony owner) — left for the caller to
// resolve some other way instead (MasterOfVegaGame.js's playPlayerBattles,
// so the human always gets the interactive tactical view rather than a
// battle they're in being silently auto-resolved during someone else's
// turn). Defaults to null so every existing headless call site (tests, the
// AI self-play soak) is completely unaffected — this is opt-in, not a
// behavior change to the engine's default. Safe to leave the OTHER pairs at
// the same star to resolve normally in the same pass: ship stacks are kept
// per (starIdx, empireIdx), so a resolved fight elsewhere in this loop can
// never touch the excluded empire's own ships.
export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
const results = [];
const byStar = new Map();
for (const f of state.fleets) {
if (f.starIdx < 0) continue;
if (!byStar.has(f.starIdx)) byStar.set(f.starIdx, []);
byStar.get(f.starIdx).push(f);
}
for (const [starIdx, fleets] of byStar) {
const empires = [...new Set(fleets.map((f) => f.empireIdx))];
const colony = colonyAt(state, starIdx);
const defenderIdx = colony ? colony.empireIdx : null;
for (const a of empires) {
for (const b of empires) {
if (a >= b) continue;
if (!atWar(state, a, b)) continue;
if (a === excludeEmpire || b === excludeEmpire) continue;
const result = fightAt(rules, state, starIdx, a, b);
if (result) results.push(result);
}
}
// A hostile fleet in orbit of a defended colony must also fight the planet.
if (colony) {
for (const a of empires) {
if (a === defenderIdx) continue;
if (!atWar(state, a, defenderIdx)) continue;
if (a === excludeEmpire || defenderIdx === excludeEmpire) continue;
if (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue;
if (colony.defenseHp <= 0) continue;
const result = fightAt(rules, state, starIdx, a, defenderIdx);
if (result) results.push(result);
}
}
}
cleanFleets(state);
return results;
}
function collectShips(rules, state, starIdx, e) {
const ships = [];
for (const f of state.fleets) {
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
for (const s of f.ships) {
const m = ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
if (m) m.count += s.count;
else ships.push({ hullId: s.hullId, mark: s.mark, count: s.count });
}
}
return ships;
}
function applyBattleLosses(rules, state, starIdx, e, survivors) {
const want = new Map();
for (const s of survivors) want.set(`${s.hullId}|${s.mark}`, s.count);
for (const f of state.fleets) {
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
for (const s of f.ships) {
const key = `${s.hullId}|${s.mark}`;
const left = want.get(key) ?? 0;
const keep = Math.min(s.count, left);
want.set(key, left - keep);
s.count = keep;
}
}
}
// Build the battle object for a pair at a system, WITHOUT resolving it.
// Split out of fightAt so the player can drive a battle tick by tick through
// VegaCombatViewV2 and then hand the outcome back — the interactive battle
// and auto-resolve therefore run the same engine and cannot diverge.
// `humanFormation` (VegaFormations.js id) is stamped onto whichever side is
// state.humanIndex, so a human-participated battle can carry the tactic the
// player actually chose (MasterOfVegaGame.js's playPlayerBattles, via its
// pre-battle formation picker) instead of createBattle's own silent-random
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
// this and are unaffected — both sides stay silently random as before.
export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) {
const colony = colonyAt(state, starIdx);
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
const attackerIdx = defenderIdx === a ? b : a;
const mkSide = (idx) => {
const emp = state.empires[idx];
return {
empireIdx: idx,
name: emp.name,
empire: { known: emp.known, traits: rules.species[emp.speciesId].traits },
ships: collectShips(rules, state, starIdx, idx),
};
};
const attacker = mkSide(attackerIdx);
const defender = mkSide(defenderIdx);
if (humanFormation) {
if (attackerIdx === state.humanIndex) attacker.formationStrategy = humanFormation;
else if (defenderIdx === state.humanIndex) defender.formationStrategy = humanFormation;
}
const defColony = colony && colony.empireIdx === defenderIdx ? colony : null;
if (!attacker.ships.length) return null;
if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null;
// The planet's typeId rides along purely for the tactical view's art (the
// real planets spritesheet instead of a generic icon) — the combat engine
// itself never reads it. shieldBonus was hard-coded to 0 here until now,
// which meant the Planetary Shield building's own effect (rules.buildings
// .planetaryshield's shieldBonus:5) was silently never applied to a real
// battle, only ever exercised through the standalone ?movsim simulator's
// manual stepper — found while wiring the shield ring into the view.
const battle = createBattle(rules, {
attacker,
defender,
colony: defColony ? {
defenseHp: defColony.defenseHp,
shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'),
typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId,
} : null,
starIdx,
rnd: () => rand(state),
});
return { battle, starIdx, attackerIdx, defenderIdx, colony: defColony };
}
// Write a finished battle's result back into the game state.
export function applyBattleOutcome(rules, state, prepared, result) {
const { starIdx, attackerIdx, defenderIdx, colony } = prepared;
applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors);
applyBattleLosses(rules, state, starIdx, defenderIdx, result.defenderSurvivors);
if (colony) colony.defenseHp = result.planetDefenseLeft;
const loser = result.winner === 'attacker' ? defenderIdx : attackerIdx;
if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser);
pushEvent(state, {
type: 'combat', starIdx, attacker: attackerIdx, defender: defenderIdx,
winner: result.winner, rounds: result.rounds,
attackerLosses: result.attackerLosses, defenderLosses: result.defenderLosses,
turn: state.turn,
});
cleanFleets(state);
return result;
}
// Systems where `e` is about to fight this turn. The scene calls this after
// movement so it can hand the player's own battles to the tactical view —
// and, since resolveCombats can now be asked to defer `e`'s battles instead
// of auto-resolving them (its `excludeEmpire` option), this is also what
// finds those deferred fights again afterward so they still get shown.
export function pendingBattlesFor(rules, state, e) {
const out = [];
const seen = new Set();
const addStar = (starIdx) => {
if (seen.has(starIdx)) return;
seen.add(starIdx);
const foes = new Set();
for (const g of state.fleets) {
if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx);
}
const colony = colonyAt(state, starIdx);
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && colony.defenseHp > 0) {
foes.add(colony.empireIdx);
}
for (const other of foes) out.push({ starIdx, other });
};
for (const f of state.fleets) {
if (f.starIdx < 0 || f.empireIdx !== e) continue;
addStar(f.starIdx);
}
// A colony of e's own with no fleet garrison at all is still a valid
// defender (planetary batteries alone) — resolveCombats' own colony-
// defense pass already covers this case; the loop above, scoped to e's
// FLEETS, previously didn't, so an undefended colony under siege during
// someone else's turn would never surface here at all.
for (const c of empireColonies(state, e)) {
addStar(c.starIdx);
}
return out;
}
function fightAt(rules, state, starIdx, a, b) {
const prepared = prepareBattleAt(rules, state, starIdx, a, b);
if (!prepared) return null;
return applyBattleOutcome(rules, state, prepared, runBattle(prepared.battle));
}
function retreatFrom(rules, state, starIdx, e) {
const own = empireColonies(state, e);
if (!own.length) {
for (const f of state.fleets) {
if (f.starIdx === starIdx && f.empireIdx === e) f.ships = [];
}
return;
}
let best = own[0].starIdx;
let bestD = Infinity;
for (const c of own) {
const d = parsecs(state.galaxy, starIdx, c.starIdx);
if (d < bestD) { bestD = d; best = c.starIdx; }
}
for (const f of state.fleets) {
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
if (best === starIdx) continue;
f.fromStar = starIdx;
f.toStar = best;
f.total = bestD;
f.progress = 0;
f.starIdx = -1;
}
}
// --------------------------------------------------------------------------
// Colonisation and invasion
export function colonize(rules, state, e, starIdx, orbit, name = null) {
if (!canColonize(rules, state, e, starIdx, orbit)) return false;
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e
&& f.ships.some((s) => s.hullId === 'colonyship' && s.count > 0));
if (!fleet) return false;
const stack = fleet.ships.find((s) => s.hullId === 'colonyship' && s.count > 0);
stack.count -= 1;
cleanFleets(state);
const colony = foundColony(rules, state, e, starIdx, orbit, 5, name);
checkContactAt(rules, state, starIdx);
pushEvent(state, { type: 'colonised', empire: e, starIdx, orbit, colonyId: colony.id, turn: state.turn });
state.empires[e]._range = null;
state.empires[e]._rangeAt = -1;
return true;
}
// --------------------------------------------------------------------------
// Population transport — MOO1's "send N population from one of your colonies
// to another." Unlike colonize()'s colony ship, this never sits in a build
// queue: the 'poptransport' hull (data/mastervega-rules.json) is dispatched
// directly, in transit already, the instant the order is given, carrying
// whatever amount was chosen as a payload on its own ship stack rather than a
// fixed per-hull capacity. It rides the same movement/fuel-range/combat
// machinery as any other fleet, so an unescorted transport crossing hostile
// space is exactly as vulnerable as a colony ship would be.
// A colony can never be fully emptied this way — same floor colony growth
// itself never goes below (see the `Math.max(0.5, ...)` in processColony).
const MIN_POP_LEFT_BEHIND = 0.5;
export function maxSendablePopulation(colony) {
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
}
export function sendPopulation(rules, state, e, fromColonyId, toColonyId, amount) {
if (fromColonyId === toColonyId) return false;
const colony = state.colonies.find((c) => c.id === fromColonyId);
if (!colony || colony.empireIdx !== e) return false;
const dest = state.colonies.find((c) => c.id === toColonyId);
if (!dest || dest.empireIdx !== e) return false;
const send = Math.min(amount, maxSendablePopulation(colony));
if (send <= 0) return false;
// Built directly in transit — see the module comment above for why this
// does not go through addFleet()/sendFleet() (which would first have to
// land in an idle fleet at the source, risking a merge with whatever is
// garrisoned there before it could be split back out). It also can't route
// through canSendFleet(): that gate rejects a destination equal to the
// fleet's own starIdx as a no-op order, which would wrongly forbid moving
// population between two colonies that share one system, so reachability
// is checked by hand here instead — same rules, minus that restriction.
const probe = { empireIdx: e, starIdx: colony.starIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
if (fleetSpeed(rules, state, probe) <= 0) return false;
if (!reachableStars(rules, state, e)[dest.starIdx]) return false;
state.fleets.push({
id: state.nextFleetId += 1,
empireIdx: e,
starIdx: -1,
fromStar: colony.starIdx,
toStar: dest.starIdx,
// Which colony to credit on arrival — starIdx alone can't disambiguate
// two colonies sharing a system (see deliverPopulation).
destColonyId: dest.id,
progress: 0,
total: parsecs(state.galaxy, colony.starIdx, dest.starIdx),
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
});
colony.pop -= send;
pushEvent(state, {
type: 'populationSent', empire: e, starIdx: colony.starIdx, toStarIdx: dest.starIdx, amount: send, turn: state.turn,
});
return true;
}
// Called from moveFleets the instant a poptransport fleet arrives. Delivers
// to a still-friendly colony there and consumes the stack; if the star no
// longer has a colony of ours (captured, or the fleet was mis-sent), the
// payload just sits inert in orbit rather than being silently discarded, in
// case the player wants to redirect it by hand later.
function deliverPopulation(rules, state, f) {
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
if (!stack) return;
// starIdx alone can't tell two same-system colonies apart, so prefer the
// colony id recorded when the transport was dispatched (see sendPopulation).
const colony = (f.destColonyId != null && state.colonies.find((c) => c.id === f.destColonyId))
|| colonyAt(state, f.starIdx);
if (!colony || colony.empireIdx !== f.empireIdx) return;
const maxPop = colonyMaxPop(rules, state, colony);
colony.pop = Math.min(maxPop, colony.pop + stack.popPayload);
pushEvent(state, {
type: 'populationDelivered', empire: f.empireIdx, starIdx: f.starIdx,
colonyId: colony.id, amount: stack.popPayload, turn: state.turn,
});
stack.count = 0;
stack.popPayload = 0;
}
// Resolves which colony at this star a bombard/invade action actually
// targets. A star can host more than one colony (one per orbit), owned by
// different empires — colonyAt() only ever returns the first one found in
// state.colonies, regardless of orbit. That silently broke Bombard/Invade
// whenever an allied or neutral colony shared a system with the actual
// (hostile) target: colonyAt picked the ally, the atWar check correctly
// refused, and the caller had no way to tell the refusal apart from a real
// "no orbital superiority" failure. `orbit`, when supplied, pins the exact
// colony the caller means — VegaSystemView.js always knows which one is on
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the
// fallback targets whichever colony here the attacker is actually at war
// with, rather than just whichever happens to be first in the array.
function targetColonyAt(state, e, starIdx, orbit) {
const here = coloniesAt(state, starIdx);
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
}
// Orbital superiority at a system: our combat power there exceeds theirs.
// Bombardment and invasion both require it.
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
const defPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
const attPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
if (attPower <= 0) return false;
return defPower <= 0 || attPower > defPower;
}
// Whether `e` has already bombarded this exact colony this turn — exported
// so the UI can grey out/hide the Bombard button proactively instead of
// letting the player click it into a silent no-op (bombard() itself returns
// null either way, same as every other precondition failure).
export function bombardedThisTurn(state, e, colony) {
return state.empires[e].lastBombardTurn[colony.id] === state.turn;
}
// Bombard a colony from orbit, killing population.
//
// This is MOO1's answer to a cornered empire, and without it conquest is
// literally unreachable: an empire reduced to one fortified homeworld survives
// forever, because its beaten fleet always retreats back to that same world and
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
// not one empire in eight games was ever eliminated. Bombing also thins the
// defenders for a subsequent landing, so the two mechanics work together.
//
// Capped at once per (attacker, colony) per turn (lastBombardTurn, keyed by
// colony.id on the attacking empire — not global, so two different empires
// bombarding the same contested world in the same turn don't block each
// other). Unlike invade(), which naturally self-limits by consuming the
// troop transports that carried it out, bombard() spends no resource of its
// own — warships aren't expended — so without this cap VegaSystemView.js's
// Bombard button could be clicked any number of times in one turn and wipe
// a colony's population instantly regardless of fleet size. This also
// quietly fixes the same bug on the AI side: VegaAI.js calls bombard() once
// per fleet it has at a hostile star, and bombard() has always summed EVERY
// attacker fleet at that star regardless of which one was passed in, so an
// AI with two fleets at one siege was double-charging the same pooled
// damage before this cap existed.
export function bombard(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null;
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
const emp = state.empires[e];
if (bombardedThisTurn(state, e, colony)) return null;
const spec = rules.species[emp.speciesId];
let damage = 0;
let cracker = false;
for (const f of state.fleets) {
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
for (const st of f.ships) {
const d = empireDesign(rules, state, e, st.hullId);
if (d.role !== 'warship') continue;
damage += d.damage * st.count;
if (d.planetCracker) cracker = true;
}
}
if (damage <= 0) return null;
emp.lastBombardTurn[colony.id] = state.turn;
const comps = empireComponents(rules, state, e);
const shield = colony.buildings.includes('planetaryshield')
? comps.planetaryShield + 5 : comps.planetaryShield;
const kill = (damage * (rules.combat.bombardPopKill ?? 0.5)) / (1 + shield * 0.15);
const before = colony.pop;
colony.pop = Math.max(0, colony.pop - kill);
// A Stellar Converter cracks the crust; nothing is left to invade.
if (cracker) colony.pop = Math.max(0, colony.pop - kill);
pushEvent(state, {
type: 'bombard', empire: e, target: colony.empireIdx, starIdx,
killed: Math.round(before - colony.pop), cracker, turn: state.turn,
});
if (colony.pop < 1) {
const owner = colony.empireIdx;
state.colonies = state.colonies.filter((c) => c !== colony);
pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn });
recomputeTotals(rules, state);
checkLastColony(state, owner, e);
checkElimination(rules, state, owner, e);
return { destroyed: true, killed: Math.round(before) };
}
recomputeTotals(rules, state);
return { destroyed: false, killed: Math.round(before - colony.pop) };
}
// What a landing at this system would look like right now. Exported because
// both the AI and the human's invade button need the same forecast — the AI
// was committing every transport it had to hopeless landings (1971 failures
// against 130 successes) purely because nothing told it the odds.
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
const invadingFleets = state.fleets.filter((f) => f.starIdx === starIdx && f.empireIdx === e);
const troops = invadingFleets
.reduce((t, f) => t + f.ships
.filter((s) => s.hullId === 'transport')
.reduce((n, s) => n + s.count, 0), 0) * (rules.hulls.transport.troops ?? 4);
if (troops <= 0) return { troops: 0, defenders: 0, odds: 0, favourable: false };
const spec = rules.species[state.empires[e].speciesId];
const comps = empireComponents(rules, state, e);
const leaderBonus = invadingFleets.reduce((t, f) => t + (fleetLeaderSkills(rules, state, f).groundAttack ?? 0), 0);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0) + leaderBonus;
const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10);
const odds = Math.max(0.1, Math.min(0.9,
0.5 + (attackBonus - defenceBonus) * (rules.combat.groundOddsScale ?? 0.01)));
// Each round is one duel; the attacker needs `defenders` wins before it takes
// `troops` losses. Expected exchange favours the landing when this holds.
const favourable = troops * odds > defenders * (1 - odds) * 1.25;
return { troops, defenders, odds, favourable };
}
export function invade(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null;
// Holding orbit means orbital SUPERIORITY, not an empty sky.
//
// Two versions of this check killed conquest outright. Requiring defenceHp to
// be zero never opened a window at all — combat resolves on the attacker's
// turn, and the defender's own turn rebuilds the batteries before the
// attacker acts again. Requiring literally no enemy hull present failed the
// same way for the same reason: a besieged colony finishes a ship every few
// turns, and that one fresh hull blocked the landing indefinitely. Measured
// over six games there were 1268 colony-turns under hostile orbit and exactly
// two invasion attempts.
const defPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
const attPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
if (defPower > 0 && attPower <= defPower) return null;
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e
&& f.ships.some((s) => s.hullId === 'transport' && s.count > 0));
if (!fleet) return null;
const stack = fleet.ships.find((s) => s.hullId === 'transport' && s.count > 0);
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
const troops = stack.count * (rules.hulls.transport.troops ?? 4);
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0)
+ (fleetLeaderSkills(rules, state, fleet).groundAttack ?? 0);
// Intact orbital batteries shell the landing zones, but only as a modifier —
// at a twelfth of their hit points they alone drove the attacker's odds to
// the 10% floor and only 21 of 268 landings succeeded.
const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
const result = resolveInvasion(rules, () => rand(state), troops, attackBonus,
{ groundDefense: defenceBonus }, defenceBonus, colony.pop);
stack.count = 0;
cleanFleets(state);
if (result.captured) {
const from = colony.empireIdx;
colony.empireIdx = e;
colony.capital = false;
colony.pop = Math.max(1, colony.pop * 0.5);
colony.queue = [];
colony.defenseHp = 0;
pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn });
state.empires[from].attitude[e] = Math.max(-100, Math.min(100, (state.empires[from].attitude[e] ?? 0) - 40));
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
checkLastColony(state, from, e);
checkElimination(rules, state, from, e);
} else {
pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn });
}
recomputeTotals(rules, state);
return result;
}
// Pushed the instant an attack (capture or bombardment) leaves the victim
// with exactly one colony — the beat GNN's "reduced to their last world"
// story reads off. Checked only from the two attack call sites that already
// know the attacker (bombard/invade); the generic end-of-turn elimination
// sweep below has no attacker in scope by construction and is not a
// colony-loss path itself, so it never needs this check.
function checkLastColony(state, victim, attacker) {
const emp = state.empires[victim];
if (!emp.alive) return;
if (empireColonies(state, victim).length === 1) {
pushEvent(state, { type: 'lastColony', empire: victim, attacker, turn: state.turn });
}
}
// `attacker` defaults to -1 ("unknown/none") for the generic end-of-turn
// sweep call site, which isn't tied to any specific attack.
function checkElimination(rules, state, e, attacker = -1) {
const emp = state.empires[e];
if (!emp.alive) return;
if (empireColonies(state, e).length > 0) return;
emp.alive = false;
// An empire with no colonies has nothing to resupply from, so its ships are
// scuttled. Leaving them on the map produced fleets belonging to a dead
// empire that nothing would ever clean up.
state.fleets = state.fleets.filter((f) => f.empireIdx !== e);
pushEvent(state, { type: 'eliminated', empire: e, attacker, turn: state.turn });
}
// --------------------------------------------------------------------------
// Galactic Council
// Share of the SETTLEABLE galaxy that is settled. Measuring against every star
// made the Council unreachable: a third of systems hold nothing but gas giants
// and asteroid belts, so "half the galaxy colonised" could never be true no
// matter how completely the map was carved up.
export function colonizedFraction(state) {
const rules = state.rules;
let habitable = 0;
for (const star of state.galaxy.stars) {
const ok = star.planets.some((p) => (rules
? rules.planetTypes[p.typeId].colonizable
: true));
if (ok) habitable += 1;
}
const owned = new Set(state.colonies.map((c) => c.starIdx));
return owned.size / Math.max(1, habitable);
}
export function runCouncil(rules, state) {
const alive = state.empires.filter((e) => e.alive);
if (alive.length < 2) return null;
const totalPop = alive.reduce((t, e) => t + e.totalPop, 0);
if (totalPop <= 0) return null;
// MOO1's rule: the two largest empires stand for High Guardian and everyone
// else votes between them. Letting every empire stand meant every empire
// simply voted for itself, so the Council convened forever and elected nobody
// — thirty sessions in an 800-turn game, all of them null.
const candidates = alive.slice().sort((a, b) => b.totalPop - a.totalPop || a.idx - b.idx).slice(0, 2);
const votes = {};
for (const c of candidates) votes[c.idx] = 0;
let abstained = 0;
// Per-voter breakdown, purely additive to the aggregate `votes` above —
// exists so VegaCouncilSession.js can replay the session one delegate at a
// time (smallest population first) instead of only ever showing the
// final tally. `choice` is a candidate idx or null (abstained).
const voters = [];
for (const voter of alive) {
const weight = voter.totalPop;
const own = candidates.find((c) => c.idx === voter.idx);
if (own) { votes[own.idx] += weight; voters.push({ idx: voter.idx, weight, choice: own.idx }); continue; }
let best = null;
let bestScore = -Infinity;
for (const cand of candidates) {
// Being at war with a candidate is disqualifying on its own.
const score = (voter.attitude[cand.idx] ?? 0) - (atWar(state, voter.idx, cand.idx) ? 60 : 0);
if (score > bestScore) { bestScore = score; best = cand; }
}
if (!best || bestScore < (rules.council.abstainAttitude ?? -20)) {
abstained += weight;
voters.push({ idx: voter.idx, weight, choice: null });
continue;
}
votes[best.idx] += weight;
voters.push({ idx: voter.idx, weight, choice: best.idx });
}
let winner = -1;
for (const c of candidates) {
if (votes[c.idx] / totalPop >= rules.council.winFraction) winner = c.idx;
}
// MOO1's rule: the defeated candidate may REFUSE TO SUBMIT. An empire that is
// at war with the winner, or simply hates them, walks out of the chamber and
// the election is void — and everyone who refused is now at war with the
// presumptive High Guardian.
//
// This is what keeps conquest reachable. Without it a dominant empire always
// won its own council vote a hundred turns before it could finish a war, and
// no game in a 24-game soak ever ended by conquest.
let refused = false;
if (winner >= 0) {
const loser = candidates.find((c) => c.idx !== winner);
const bitter = loser && (atWar(state, loser.idx, winner)
|| (loser.attitude[winner] ?? 0) < (rules.council.submitAttitude ?? -25));
if (bitter) {
refused = true;
pushEvent(state, { type: 'councilRefused', empire: loser.idx, winner, turn: state.turn });
if (!atWar(state, loser.idx, winner)) declareWar(rules, state, loser.idx, winner);
// Everyone who withheld their vote resents the near-coronation.
for (const o of alive) {
if (o.idx === winner) continue;
o.attitude[winner] = Math.max(-100, (o.attitude[winner] ?? 0) - 15);
}
winner = -1;
}
}
const result = {
turn: state.turn, votes, totalPop, abstained, winner, refused,
candidates: candidates.map((c) => c.idx), voters,
};
state.council.lastResult = result;
state.council.history.push(result);
state.council.nextTurn = state.turn + rules.council.interval;
// Consumed by MasterOfVegaGame.js's runToHumanTurn() the moment control
// returns to the human, which opens VegaCouncilSession.js's ceremony
// BEFORE the state.over victory check — so a decisive session still gets
// its ceremony instead of jumping straight to the victory overlay.
state.council.pendingSession = true;
pushEvent(state, { type: 'council', ...result });
if (winner >= 0) {
state.over = true;
state.winnerIdx = winner;
state.victoryKind = 'council';
pushEvent(state, { type: 'victory', empire: winner, kind: 'council', turn: state.turn });
}
return result;
}
function checkVictory(rules, state) {
if (state.over) return;
const alive = state.empires.filter((e) => e.alive);
if (alive.length <= 1) {
state.over = true;
state.winnerIdx = alive.length ? alive[0].idx : -1;
state.victoryKind = 'conquest';
pushEvent(state, { type: 'victory', empire: state.winnerIdx, kind: 'conquest', turn: state.turn });
return;
}
if (state.turn >= (rules.victory.turnCap ?? 800)) {
// Out of time: the largest empire is declared dominant so a game always
// terminates. The soak relies on this.
const best = alive.reduce((x, y) => (y.totalPop > x.totalPop ? y : x), alive[0]);
state.over = true;
state.winnerIdx = best.idx;
state.victoryKind = 'timeout';
pushEvent(state, { type: 'victory', empire: best.idx, kind: 'timeout', turn: state.turn });
}
}
// --------------------------------------------------------------------------
// Turn sequencing
export function moveFleetsFor(rules, state, e) { moveFleets(rules, state, e); }
// `deferBattlesFor` threads straight through to resolveCombats'
// `excludeEmpire` — see that function's own comment. Defaults to null
// (resolve everything, exactly as before) so no existing caller is affected;
// MasterOfVegaGame.js is the only caller that passes it, for every
// endEmpireTurn call in the human's turn — its own AND every AI empire's —
// so a battle the human is in NEVER auto-resolves, only ever through the
// interactive tactical view (Brian's ask, 2026-08-14).
export function endEmpireTurn(rules, state, e, { skipMove = false, deferBattlesFor = null } = {}) {
if (state.empires[e].alive) {
if (!skipMove) moveFleets(rules, state, e);
resolveCombats(rules, state, { excludeEmpire: deferBattlesFor });
for (const emp of state.empires) checkElimination(rules, state, emp.idx);
}
let next = e;
for (let i = 0; i < state.empires.length; i += 1) {
next = (next + 1) % state.empires.length;
if (next === 0) {
state.turn += 1;
state.rules = rules;
// Border/fleet proximity tension, fleet-parked-at-your-colony
// complaints, and enemy-of-my-enemy triangulation — whole-galaxy
// passes, run once per calendar turn rather than once per empire, and
// before the Council so its vote reads this turn's fresh attitude.
runGalaxyDiplomacyPass(rules, state);
if (rules.council.council !== false
&& state.turn >= state.council.nextTurn
&& colonizedFraction(state) >= rules.council.minColonizedFraction) {
runCouncil(rules, state);
}
// A galaxy where nobody ever fights and no Council vote carries will run
// to the turn cap and be decided by a tiebreak, which is the least
// interesting outcome available. Past a point, force the issue.
const nudge = rules.victory.stalemateTurn ?? 150;
if (!state.over && state.turn > nudge && state.turn % 40 === 0) breakStalemate(rules, state);
checkVictory(rules, state);
}
if (state.empires[next].alive) break;
}
state.current = next;
}
// --------------------------------------------------------------------------
// Player actions
export function setSlider(rules, state, colony, channel, value) {
const v = Math.max(0, Math.min(1, value));
const others = CHANNELS.filter((c) => c !== channel && !colony.locked[c]);
const lockedSum = CHANNELS.filter((c) => c !== channel && colony.locked[c])
.reduce((t, c) => t + colony.sliders[c], 0);
const room = Math.max(0, 1 - lockedSum);
const target = Math.min(v, room);
colony.sliders[channel] = target;
const rest = room - target;
const otherSum = others.reduce((t, c) => t + colony.sliders[c], 0);
if (others.length === 0) return;
if (otherSum <= 0) {
for (const c of others) colony.sliders[c] = rest / others.length;
} else {
for (const c of others) colony.sliders[c] = (colony.sliders[c] / otherSum) * rest;
}
}
// A locked field holds its share while the rest renormalise — same contract
// as setSlider/colony.locked, transposed onto emp.alloc/emp.allocLocked.
export function setResearchAlloc(rules, state, e, field, value) {
const emp = state.empires[e];
emp.allocLocked ??= {};
if (emp.allocLocked[field]) return;
const fields = Object.keys(rules.techFields);
const v = Math.max(0, Math.min(1, value));
const others = fields.filter((f) => f !== field && !emp.allocLocked[f]);
const lockedSum = fields.filter((f) => f !== field && emp.allocLocked[f])
.reduce((t, f) => t + emp.alloc[f], 0);
const room = Math.max(0, 1 - lockedSum);
const target = Math.min(v, room);
emp.alloc[field] = target;
const rest = room - target;
if (others.length === 0) return;
const sum = others.reduce((t, f) => t + emp.alloc[f], 0);
if (sum <= 0) for (const f of others) emp.alloc[f] = rest / others.length;
else for (const f of others) emp.alloc[f] = (emp.alloc[f] / sum) * rest;
}
export function enqueue(rules, state, colony, kind, id) {
if (kind === 'building') {
if (colony.buildings.includes(id)) return false;
if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) return false;
const b = rules.buildings[id];
const emp = state.empires[colony.empireIdx];
if (b.prereq && !emp.known[b.prereq]) return false;
}
colony.queue.push({ kind, id, progress: 0 });
return true;
}
/**
* Queue `n` copies of the same thing — the repeat-count button on the colony
* screen. They are stored as n SEPARATE entries rather than one entry with a
* count, so `processColony` and the save format need no knowledge of repeats at
* all; the view is what collapses a run of identical ships into a "x N" row.
* Buildings are unique per colony, so `enqueue` refuses every copy after the
* first and this returns 1. Returns how many were actually added.
*/
export function enqueueMany(rules, state, colony, kind, id, n) {
let added = 0;
for (let i = 0; i < Math.max(0, Math.floor(n)); i += 1) {
if (!enqueue(rules, state, colony, kind, id)) break;
added += 1;
}
return added;
}
// --------------------------------------------------------------------------
// Colony Focus — the colony screen's per-colony autopilot (VegaColonyView.js).
// Runs once per human turn, right after that empire's own beginEmpireTurn
// (MasterOfVegaGame.js's runToHumanTurn), and mirrors the shape of VegaAI.js's
// manageColony building ladder / preferredWarship. Deliberately reimplemented
// rather than imported: VegaAI.js already imports FROM this file, so pulling
// AI helpers in here would cycle, and duplicating a dozen lines of scoring
// logic is cheaper than restructuring either file around a shared surface.
/**
* First candidate in `ids` that clears enqueue's own guards (already-built,
* already-queued, prereq) plus an affordability guard mirroring VegaAI.js's
* `if (b.cost > prod * 25) continue;` — so a small colony is never saddled
* with upkeep it cannot carry. Pure — used both to decide what to actually
* queue (enqueueFirstAffordableBuilding) and, read-only, by the advisor
* recommendation heuristics (recommendColonyFocus/recommendAllocationFocus)
* to check "would something here actually get built."
*/
function firstEligibleBuildingId(rules, state, colony, ids) {
const prod = colonyProduction(rules, state, colony);
const emp = state.empires[colony.empireIdx];
for (const id of ids) {
const b = rules.buildings[id];
if (!b) continue;
if (colony.buildings.includes(id)) continue;
if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
if (b.cost > prod * 25) continue;
return id;
}
return null;
}
function enqueueFirstAffordableBuilding(rules, state, colony, ids) {
const id = firstEligibleBuildingId(rules, state, colony, ids);
return id ? enqueue(rules, state, colony, 'building', id) : false;
}
const byCostAsc = (a, b) => a.cost - b.cost;
// Candidate id lists, shared between the FOCUS_PICKERS below (which actually
// enqueue) and the advisor recommendation heuristics further down (which only
// need to know whether something in the category is eligible).
const industryBuildingIds = (rules) => rules.buildingList
.filter((b) => b.channel === 'industry').sort(byCostAsc).map((b) => b.id);
const otherBuildingIds = (rules) => rules.buildingList
.filter((b) => b.channel !== 'industry').sort(byCostAsc).map((b) => b.id);
const researchBuildingIds = (rules) => rules.buildingList
.filter((b) => b.channel === 'research').sort(byCostAsc).map((b) => b.id);
const growthBuildingIds = (rules) => rules.buildingList
.filter((b) => b.effects?.maxPopBonus || b.effects?.growthMult).sort(byCostAsc).map((b) => b.id);
const tradeBuildingIds = (rules) => rules.buildingList
.filter((b) => b.effects?.tradeBonus || b.effects?.tradeMult).sort(byCostAsc).map((b) => b.id);
const defenseBuildingIds = (rules) => rules.buildingList
.filter((b) => b.channel === 'defense').sort(byCostAsc).map((b) => b.id);
const anyBuildingIds = (rules) => rules.buildingList.slice().sort(byCostAsc).map((b) => b.id);
function pickImprovement(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, [...industryBuildingIds(rules), ...otherBuildingIds(rules)]);
}
// No fallback if nothing qualifies — an empty queue is correct here, since
// processColony already spills unspent construction BC into research.
function pickResearchOnly(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, researchBuildingIds(rules));
}
function pickGrowth(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, growthBuildingIds(rules));
}
function pickTrade(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, tradeBuildingIds(rules));
}
function pickDefense(rules, state, colony) {
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
}
// Brian's target fleet mix for Colony Focus: Fleet Production — 4 frigates :
// 3 destroyers : 2 cruisers : 1 battleship. Any warship hull missing from
// this table (there shouldn't be one — frigate/destroyer/cruiser/battleship
// are the only role:'warship' hulls in mastervega-rules.json) falls back to
// weight 1.
const FLEET_MIX_WEIGHT = { frigate: 4, destroyer: 3, cruiser: 2, battleship: 1 };
// Independently-implemented twin of VegaAI.js's preferredWarship (same
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
// cutoff — see the header note above for why this is duplicated rather than
// shared), plus a weighted diversity pass: rather than always maxing out the
// single best-value hull, it counts what's already docked at this colony's
// star and favours whichever warship role is furthest below its target share
// of FLEET_MIX_WEIGHT (count/weight, lowest wins — the standard weighted
// round-robin comparison), ties broken by score. Starting from an empty
// fleet this converges to the 4:3:2:1 ratio after one full 10-ship cycle and
// holds it indefinitely, rather than the old equal-count-per-hull split.
function pickFleet(rules, state, colony) {
const e = colony.empireIdx;
const budget = colonyBuildRate(rules, state, colony);
const hullIds = rules.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
const present = {};
for (const f of fleetsAt(state, colony.starIdx)) {
if (f.empireIdx !== e) continue;
for (const s of f.ships) {
if (hullIds.includes(s.hullId)) present[s.hullId] = (present[s.hullId] ?? 0) + s.count;
}
}
let best = null;
let bestRatio = Infinity;
let bestScore = -Infinity;
for (const hullId of hullIds) {
const d = empireDesign(rules, state, e, hullId);
if (d.damage <= 0) continue;
if (d.cost > budget * 15) continue;
const count = present[hullId] ?? 0;
const weight = FLEET_MIX_WEIGHT[hullId] ?? 1;
const ratio = count / weight;
const score = (d.hp + d.damage * 4) / d.cost;
if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
best = hullId; bestRatio = ratio; bestScore = score;
}
}
// Before any weapon tech is known every hull scores damage=0 and `best`
// stays null — VegaAI.js's preferredWarship falls back to a bare frigate
// in that same situation rather than building nothing, and Fleet
// Production mirrors it: an empty queue every turn until the first weapon
// is researched would read as the focus doing nothing at all.
enqueue(rules, state, colony, 'ship', best ?? 'frigate');
}
// How many of `hullId` this empire already has committed, empire-wide: built
// and sitting in a fleet, PLUS still under construction in any colony's
// queue. Counting the queue too keeps every expansion-focused colony reading
// the same live total instead of each one independently topping up toward
// the same target the instant its own queue goes empty — pickExpansion calls
// this every time it is asked for the next item, so double-committing would
// otherwise be the default outcome, not an edge case.
function empireHullCommitment(state, e, hullId) {
let n = 0;
for (const f of empireFleets(state, e)) {
for (const s of f.ships) if (s.hullId === hullId) n += s.count;
}
for (const colony of empireColonies(state, e)) {
for (const item of colony.queue) if (item.kind === 'ship' && item.id === hullId) n += 1;
}
return n;
}
// Colony Focus: Galactic Expansion (Brian's ask, 2026-08-14). One colony
// ship for every discovered-but-unclaimed world (discoveredOpenWorlds), then
// — once that demand is covered — a standing 2-frigates-per-colony-ship
// escort ratio empire-wide. This is a RATIO, not a literal per-ship escort
// assignment (mastervega has no ship-to-ship escort tagging); "two frigates
// exist for every colony ship in the fleet" is what satisfies it. No
// fallback once both targets are met, same as pickResearchOnly — an empty
// queue is correct and processColony spills the unspent BC into research.
function pickExpansion(rules, state, colony) {
const e = colony.empireIdx;
const openTargets = discoveredOpenWorlds(rules, state, e);
if (empireHullCommitment(state, e, 'colonyship') < openTargets) {
enqueue(rules, state, colony, 'ship', 'colonyship');
return;
}
let colonyShipsBuilt = 0;
for (const f of empireFleets(state, e)) {
for (const s of f.ships) if (s.hullId === 'colonyship') colonyShipsBuilt += s.count;
}
if (empireHullCommitment(state, e, 'frigate') < colonyShipsBuilt * 2) {
enqueue(rules, state, colony, 'ship', 'frigate');
}
}
const FOCUS_PICKERS = {
improvement: pickImprovement,
research: pickResearchOnly,
fleet: pickFleet,
expansion: pickExpansion,
growth: pickGrowth,
trade: pickTrade,
defense: pickDefense,
};
/**
* For every colony of empire `e` with a non-manual focus and an empty queue,
* enqueue at most one thing. Called once per human turn — see
* MasterOfVegaGame.js's runToHumanTurn for the exact hook point.
*/
export function autoQueueColonies(rules, state, e) {
for (const colony of empireColonies(state, e)) {
if (!colony.focus || colony.focus === 'manual') continue;
if (colony.queue.length > 0) continue;
FOCUS_PICKERS[colony.focus]?.(rules, state, colony);
}
}
// --------------------------------------------------------------------------
// Advisor recommendations — what the Colony Focus / Allocation Focus flyouts
// (VegaColonyView.js) point at, and what checkAdvisorRecommendations compares
// the current setting against once a turn. Both functions are pure (never
// mutate, never enqueue) so they are cheap to call on every flyout render.
/**
* Empire-wide fleet strength — the same computation VegaAI.js's
* computeStrategy uses for its own `myFleet` (VegaAI.js:71), reused here so
* "fleet condition of the species" means what the AI already judges itself
* by, rather than a second invented metric. Exported (renamed from the old
* human-only `myFleetPower`) so GNN's Military ranking page can compute the
* same number for every empire, not just the human's.
*/
export function empireFleetPower(rules, state, e) {
return empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0);
}
// VegaAI.js's own peacetime fleet-adequacy threshold (VegaAI.js:180),
// reused rather than inventing a separate number.
const fleetAdequacyThreshold = (state) => 400 + state.turn * 4;
// How urgently the advisors push "Fleet Production" (recommendColonyFocus's
// fleet rung) — a multiplier on fleetAdequacyThreshold rather than a flat
// yes/no, so it fires on a sliding bar instead of ever getting stuck fully
// on or off. Before first contact there is nobody to threaten the empire or
// be threatened by, so the rung is throttled hard rather than firing off the
// raw baseline alone (Brian's ask). Once contact exists, the COLDEST
// contacted relationship sets the tone rather than the average — one
// species turning hostile matters more than three others staying friendly —
// so a fleet the player hasn't needed yet can suddenly read as advisable
// again, and eases back off once every contact is warm. Mirrors
// VegaDiplomacy.js's moodOf tiers (-60/-20/20/60) without importing it:
// VegaDiplomacy.js imports FROM this file, so the reverse import would be
// circular.
export function fleetUrgency(state, e) {
const others = state.empires.filter((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
if (!others.length) return { multiplier: 0.25, threat: null };
let coldest = others[0];
for (const o of others) {
if ((o.attitude[e] ?? 0) < (coldest.attitude[e] ?? 0)) coldest = o;
}
const att = coldest.attitude[e] ?? 0;
if (att <= -60) return { multiplier: 1.5, threat: { empire: coldest, moodWord: 'hostile' } };
if (att <= -20) return { multiplier: 1.15, threat: { empire: coldest, moodWord: 'cold' } };
if (att < 20) return { multiplier: 0.85, threat: null };
return { multiplier: 0.5, threat: null };
}
/**
* First applicable rung of a priority ladder — deliberately mirrors
* FOCUS_PICKERS' order, but reports the first CATEGORY that applies rather
* than the first one enqueue would accept, and always resolves to something
* (falls through to 'manual') so the colony screen always has a row to point
* at.
*/
export function recommendColonyFocus(rules, state, colony) {
const star = state.galaxy.stars[colony.starIdx];
const planet = star.planets[colony.orbit];
const type = rules.planetTypes[planet.typeId];
const maxPop = colonyMaxPop(rules, state, colony);
const e = colony.empireIdx;
const expansion = expansionFavored(rules, state, e);
if (expansion.favored) {
return {
value: 'expansion', label: 'Galactic Expansion',
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
+ 'grab territory now while the galaxy is still open.',
};
}
const growthId = firstEligibleBuildingId(rules, state, colony, growthBuildingIds(rules));
if (colony.pop < maxPop * 0.7 && growthId) {
const pct = Math.round((colony.pop / maxPop) * 100);
return {
value: 'growth', label: 'Population Growth',
reason: `This ${type.name.toLowerCase()} world is at ${pct}% of its population ceiling — `
+ `a ${rules.buildings[growthId].name} would raise it before this colony turns to military objectives.`,
};
}
// Two separate reasons to recommend Colony Improvement here, both ahead of
// Fleet Production in this ladder: factories deep in the red (< 70% of cap
// — Brian's ask, 2026-08-14, same "red" line recommendAllocationFocus's
// own Industrial Buildout rung already used) mean this colony hasn't built
// out its own infrastructure yet and shouldn't be steered toward warships
// before it has; factories nearly AT cap (>= 90%) is the opposite
// situation — capacity is about to run out and another building keeps
// growth going. Both point at the same fix, so they share one rung.
const industryId = firstEligibleBuildingId(rules, state, colony, industryBuildingIds(rules));
const factoryCap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
const factoriesBehind = effF < factoryCap * 0.7;
const factoriesNearCap = effF >= factoryCap * 0.9;
if ((factoriesBehind || factoriesNearCap) && industryId) {
return {
value: 'improvement', label: 'Colony Improvement',
reason: factoriesBehind
? `Factories are well below your cap (${Math.floor(effF)}/${factoryCap}) — build up this colony's `
+ `infrastructure with a ${rules.buildings[industryId].name} before turning toward military objectives.`
: `Factories are running at ${Math.floor(effF)}/${factoryCap}`
+ `a ${rules.buildings[industryId].name} would raise your production ceiling.`,
};
}
const { multiplier: fleetMultiplier, threat } = fleetUrgency(state, e);
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state) * fleetMultiplier) {
return {
value: 'fleet', label: 'Fleet Production',
reason: threat
? `Empire-wide fleet strength is below what's typical this far into the game, and relations `
+ `with ${threat.empire.name} have turned ${threat.moodWord} — additional warships would help, `
+ 'built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.'
: "Empire-wide fleet strength is below what's typical this far into the game — "
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
};
}
const tradeIds = tradeBuildingIds(rules);
const hasTrade = colony.buildings.some((b) => tradeIds.includes(b));
const tradeId = firstEligibleBuildingId(rules, state, colony, tradeIds);
if (!hasTrade && colony.pop >= 3 && tradeId) {
return {
value: 'trade', label: 'Trade & Commerce',
reason: `This colony has no trade infrastructure yet — a ${rules.buildings[tradeId].name} `
+ 'would raise its BC income.',
};
}
const atWarWithAnyone = state.empires.some((o) => o.alive && o.idx !== e && atWar(state, e, o.idx));
const defenseId = firstEligibleBuildingId(rules, state, colony, defenseBuildingIds(rules));
const defenseCap = colonyDefenseCap(rules, state, colony);
if (atWarWithAnyone && colony.defenseHp < defenseCap * 0.5 && defenseId) {
return {
value: 'defense', label: 'Homeworld Defense',
reason: `Your empire is at war and this colony's defences are under-built `
+ `(${Math.round(colony.defenseHp)}/${defenseCap}) — a ${rules.buildings[defenseId].name} would help repel raids.`,
};
}
const researchId = firstEligibleBuildingId(rules, state, colony, researchBuildingIds(rules));
if (colony.pop >= 3 && researchId) {
return {
value: 'research', label: 'Research Focus',
reason: `A ${rules.buildings[researchId].name} would raise this colony's contribution to empire research.`,
};
}
const anyId = firstEligibleBuildingId(rules, state, colony, anyBuildingIds(rules));
if (anyId) {
return {
value: 'improvement', label: 'Colony Improvement',
reason: `There's still room to grow this colony's infrastructure — a ${rules.buildings[anyId].name} is available.`,
};
}
return {
value: 'manual', label: 'Manual',
reason: 'This colony is fully built out and the fleet is in good shape — steer it yourself as opportunities come up.',
};
}
/** Same signal order as recommendColonyFocus, mapped to slider presets instead of buildings. */
export function recommendAllocationFocus(rules, state, colony) {
const maxPop = colonyMaxPop(rules, state, colony);
const factoryCap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
const e = colony.empireIdx;
const expansion = expansionFavored(rules, state, e);
if (expansion.favored) {
return {
key: 'expansion', label: 'Expansion',
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
+ 'fund colony ships and their escorts over everything else.',
};
}
if (colony.pop < maxPop * 0.7 || colony.waste > 3) {
return {
key: 'growth', label: 'Population Growth',
reason: colony.waste > 3
? `Uncleaned waste (${colony.waste.toFixed(1)}) is capping this colony's population — `
+ 'more Ecology funding clears it fastest.'
: `Population is at ${Math.round((colony.pop / maxPop) * 100)}% of its ceiling — `
+ 'more Ecology funding clears the way for growth.',
};
}
if (effF < factoryCap * 0.7) {
return {
key: 'production', label: 'Industrial Buildout',
reason: `Factories are still well below your cap (${Math.floor(effF)}/${factoryCap}) — `
+ 'more Industry funding builds them out faster.',
};
}
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) {
return {
key: 'military', label: 'Military Buildup',
reason: "Empire-wide fleet strength is below what's typical this far into the game.",
};
}
// Deliberately NOT `colony.sliders.research < someThreshold`: the Default
// and Research Focus presets sit at 0.20 and 0.50 respectively, so reading
// the colony's own current slider back would just report which preset was
// picked last — recommend Default while on Research (0.50 isn't low) and
// recommend Research while on Default (0.20 is low), flipping every time
// the player follows the advice. A research building still worth putting
// up is a fact about the colony, not about whichever preset is live, so it
// can't oscillate the same way — same signal recommendColonyFocus's own
// research rung uses.
const researchId = firstEligibleBuildingId(rules, state, colony, researchBuildingIds(rules));
if (colony.pop >= 3 && researchId) {
return {
key: 'research', label: 'Research Focus',
reason: `Nothing more urgent elsewhere — a ${rules.buildings[researchId].name} would raise this `
+ "colony's contribution to empire research.",
};
}
return {
key: 'default', label: 'Default',
reason: 'A balanced split fits this colony well right now.',
};
}
/**
* Set a colony's Colony Focus autopilot and arm the advisor's quiet timer —
* shared by VegaColonyView.js's flyout and VegaColoniesScreen.js's inline
* pill so both apply exactly the same side effect rather than two copies of
* it drifting apart.
*/
export function applyColonyFocus(state, colony, focusValue) {
colony.focus = focusValue;
colony.advisor.focusQuietUntil = state.turn + 15;
}
/**
* Overwrite a colony's allocation sliders with a preset and arm the
* advisor's quiet timer. A deliberate bulk overwrite that BYPASSES
* colony.locked (unlike setSlider) — `opt` is one of VegaColonyView.js's
* ALLOCATION_FOCUS_OPTIONS, `{ key, sliders }`.
*/
export function applyAllocationFocus(state, colony, opt) {
colony.sliders = { ...opt.sliders };
colony.advisor.allocKey = opt.key;
colony.advisor.allocQuietUntil = state.turn + 15;
}
/**
* The Colonies screen's (VegaColoniesScreen.js) advisor commentary for one
* colony: the same recommended Colony Focus / Allocation Focus judgment the
* single-colony flyouts already make, plus explicit deficiency call-outs,
* as an ordered array of short lines for the terminal panel to type out.
* Pure — never mutates.
*/
export function advisorColonyReport(rules, state, colony) {
const maxPop = colonyMaxPop(rules, state, colony);
const factoryCap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
const defenseCap = colonyDefenseCap(rules, state, colony);
const lines = [];
const focus = recommendColonyFocus(rules, state, colony);
lines.push(`COLONY FOCUS: ${focus.label}`);
lines.push(focus.reason);
const alloc = recommendAllocationFocus(rules, state, colony);
lines.push(`ALLOCATION FOCUS: ${alloc.label}`);
lines.push(alloc.reason);
const deficiencies = [];
const popPct = Math.round((colony.pop / Math.max(1, maxPop)) * 100);
if (popPct < 70) {
deficiencies.push(`Population is at ${popPct}% of what this world can support.`);
}
if (effF < factoryCap * 0.7) {
deficiencies.push(`Factories are under-built: ${Math.floor(effF)} of ${factoryCap}.`);
}
if (colony.factories > effF + 0.5) {
deficiencies.push(`${Math.floor(colony.factories - effF)} factories sit mothballed — too few people to staff them.`);
}
if (colony.waste > 3) {
deficiencies.push(`Uncleaned waste (${colony.waste.toFixed(1)}) is capping population growth.`);
}
if (colony.defenseHp < defenseCap * 0.5) {
deficiencies.push(`Defences are thin: ${Math.round(colony.defenseHp)} of ${defenseCap}.`);
}
if (deficiencies.length) {
lines.push('DEFICIENCIES:');
lines.push(...deficiencies);
}
return lines;
}
/**
* Once-a-turn re-analysis. Only touches a colony whose advisor tracking has
* been armed (a Quiet* field is non-null, meaning the player picked
* something at least once in that flyout) and whose quiet period has
* elapsed. Notifies at most once per still-outstanding recommendation — the
* Notified* markers dedupe so a persisting mismatch doesn't repeat every
* turn, and clear themselves the moment the setting matches the current
* recommendation again so a later divergence is reported fresh.
*/
export function checkAdvisorRecommendations(rules, state, e) {
for (const colony of empireColonies(state, e)) {
const adv = colony.advisor;
if (!adv) continue;
const domains = [];
if (adv.focusQuietUntil != null && state.turn >= adv.focusQuietUntil) {
const rec = recommendColonyFocus(rules, state, colony);
if (rec.value === colony.focus) {
adv.focusNotifiedValue = null;
} else if (rec.value !== adv.focusNotifiedValue) {
adv.focusNotifiedValue = rec.value;
domains.push('focus');
}
}
if (adv.allocQuietUntil != null && state.turn >= adv.allocQuietUntil) {
const rec = recommendAllocationFocus(rules, state, colony);
if (rec.key === adv.allocKey) {
adv.allocNotifiedKey = null;
} else if (rec.key !== adv.allocNotifiedKey) {
adv.allocNotifiedKey = rec.key;
domains.push('alloc');
}
}
if (domains.length) {
pushEvent(state, {
type: 'advisorRecommendation', empire: e, colonyId: colony.id, starIdx: colony.starIdx,
domains, turn: state.turn,
});
}
}
}
export function dequeue(rules, state, colony, index) {
if (index < 0 || index >= colony.queue.length) return false;
colony.queue.splice(index, 1);
return true;
}
/**
* Reorder the queue. `progress` is a property of the item object, so it travels
* with the move for free — demoting the half-built head item banks its BC
* rather than losing them, and promoting something else does not hand it the
* head's progress. `processColony` only ever looks at `queue[0]`, so a splice
* between turns is safe.
*/
export function moveQueueItem(rules, state, colony, from, to) {
const n = colony.queue.length;
if (from < 0 || from >= n) return false;
const dest = Math.max(0, Math.min(n - 1, to));
if (dest === from) return true;
const [item] = colony.queue.splice(from, 1);
colony.queue.splice(dest, 0, item);
return true;
}
/**
* Collapse the queue into display runs: consecutive identical ships become one
* `{ index, lastIndex, item, count }` row. `enqueueMany` stores a repeat as N
* separate entries so the turn processor and the save format never learn about
* repeats, and this is what folds them back up for a UI.
*
* A part-built entry never joins a run — its progress has to stay individually
* legible, and it is always at the head anyway, since slot 0 is the only one
* the engine ever spends on.
*/
export function collapseQueue(colony) {
const rows = [];
colony.queue.forEach((item, index) => {
const last = rows[rows.length - 1];
const runnable = item.kind === 'ship' && (item.progress ?? 0) === 0;
if (last && runnable && last.item.kind === 'ship' && last.item.id === item.id
&& (last.item.progress ?? 0) === 0) {
last.count += 1;
last.lastIndex = index;
return;
}
rows.push({ index, lastIndex: index, item, count: 1 });
});
return rows;
}
/**
* Move a whole collapsed run one row up (dir -1) or down (dir +1), where `rows`
* came from `collapseQueue`. A "x 5" row is five queue entries, so this is five
* splices — and the two directions do NOT use the same indices, because every
* splice shifts what comes after it:
*
* up — the copies still waiting behind keep their original slots, so both
* ends of the move advance together (`+ k`).
* down — pulling the head copy out slides the rest of the run down into the
* slot it just vacated, so the SAME pair of indices moves every copy.
*
* Getting this wrong does not throw; it silently interleaves the run with its
* neighbour, which is why it is here in the headless tier and checked in the
* verifier rather than left in the view.
*/
export function moveQueueRun(rules, state, colony, rows, ri, dir) {
const run = rows[ri];
const neighbour = rows[ri + dir];
if (!run || !neighbour) return false;
for (let k = 0; k < run.count; k += 1) {
if (dir < 0) moveQueueItem(rules, state, colony, run.index + k, neighbour.index + k);
else moveQueueItem(rules, state, colony, run.index, neighbour.lastIndex);
}
return true;
}
// A leader's list price (rules.leaders[id].hireCost) is fixed; what an
// empire actually pays climbs with how many leaders it already has on
// staff, so stockpiling BC and clearing the shared pool cheaply stops being
// the dominant strategy once a roster gets going. The 1st hire is always
// list price.
export function leaderHireCost(rules, state, e, leaderId) {
const leader = rules.leaders[leaderId];
if (!leader) return Infinity;
const owned = state.empires[e].leaders.length;
return Math.round(leader.hireCost * (1 + owned * rules.leaderHiring.costScalePerOwned));
}
export function hireLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const leader = rules.leaders[leaderId];
if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false;
const cost = leaderHireCost(rules, state, e, leaderId);
if (emp.bc < cost) return false;
emp.bc -= cost;
emp.leaders.push({ leaderId, assignKind: null, assignId: -1 });
pushEvent(state, {
type: 'leaderHired', empire: e, leaderId, cost, turn: state.turn,
});
return true;
}
export function assignLeader(rules, state, e, leaderId, kind, id) {
const emp = state.empires[e];
const l = emp.leaders.find((x) => x.leaderId === leaderId);
if (!l) return false;
const leader = rules.leaders[leaderId];
if (leader.kind === 'admin' && kind !== 'colony') return false;
if (leader.kind === 'captain' && kind !== 'fleet') return false;
// One leader per posting.
for (const other of emp.leaders) {
if (other !== l && other.assignKind === kind && other.assignId === id) other.assignId = -1, other.assignKind = null;
}
l.assignKind = kind;
l.assignId = id;
return true;
}
export function unassignLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const l = emp.leaders.find((x) => x.leaderId === leaderId);
if (!l || l.assignKind === null) return false;
l.assignKind = null;
l.assignId = -1;
return true;
}
// --------------------------------------------------------------------------
// Serialisation
export function serialize(state) {
const { rules, ...rest } = state;
// Every underscore-prefixed field is a derived memo cache (component bags,
// range sets and the turn stamps that invalidate them). Dropping the values
// but keeping the stamps would make a reloaded game serialise differently
// from the one it was saved from, so the whole prefix goes.
return JSON.stringify(rest, (key, value) => (key.startsWith('_') ? undefined : value));
}
export function deserialize(json) {
const state = JSON.parse(json);
if (state.version !== 1) return null;
// A save from before pushEvent stamped events with `seq` has none on any
// existing event — nextEventSeq starts at 0 either way (pushEvent uses
// pre-increment), so the first NEW event in a reloaded old save gets seq 1,
// safely past every un-seq'd historical event's implicit `undefined`
// (always fails `ev.seq > cursor`, so old events are just never
// war-memory-countable, same cold-start tradeoff as every other
// back-filled field here).
state.nextEventSeq ??= 0;
for (const emp of state.empires) {
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
emp._designs = null; emp._designsAt = -1;
// A save from before the research-lock padlock existed has no allocLocked
// at all — back-fill it the same way a fresh empire gets one, rather than
// bumping the save version over one new always-empty-by-default field.
emp.allocLocked ??= {};
// Same convention for the diplomacy-expansion fields: trade agreements,
// gift cooldown tracking, and foreign-fleet dwell tracking.
emp.tradeAgreements ??= {};
emp.lastGiftTurn ??= {};
emp.lastBombardTurn ??= {};
emp.fleetIntrusions ??= {};
emp.espionageMission ??= {};
emp.warMemory ??= {};
emp.warMemorySeq ??= 0;
}
// Same convention for a save from before GNN existed — but a bare
// `gnn ??= {history:[]}` is not enough here: state.events is never cleared
// (only capped/trimmed), so a save with retained war/peace/tech events
// would otherwise have every one of them treated as a brand-new unreported
// GNN story the instant the player next ends a turn, flooding them with
// retroactive news. Marking every existing event gnnAnnounced up front
// prevents that burst — GNN only ever reports what happens from here on.
if (!state.gnn) {
state.gnn = { history: [] };
for (const ev of state.events) ev.gnnAnnounced = true;
}
// Same convention for a save from before the Council Session ceremony
// existed — an old save can never be mid-session (the feature didn't
// exist to leave one pending), so false is always correct here.
state.council.pendingSession ??= false;
// Same convention for a save from before Colony Focus existed.
for (const c of state.colonies) {
c.focus ??= 'manual';
c.advisor ??= {
focusQuietUntil: null, focusNotifiedValue: null,
allocQuietUntil: null, allocKey: null, allocNotifiedKey: null,
};
}
return state;
}
export function hashState(state) {
const str = serialize(state);
let h = 2166136261;
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(16);
}