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

1844 lines
73 KiB
JavaScript

// 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 } from './VegaGalaxyGen.js';
import { techCost } from './VegaRules.js';
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
import { createBattle, runBattle, resolveInvasion } from './VegaCombat.js';
import { breakStalemate, declareWar } 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; }
const pushEvent = (state, ev) => {
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);
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;
}
// --------------------------------------------------------------------------
// 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;
}
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
function rollTechAvailability(state, rules, emp, spec) {
const available = {};
// A species that "can research anything" gets the whole tree; everyone else
// rolls per tech, weighted by how naturally the field comes to them. Because
// the chains are linear, an unavailable tech does NOT block the rest of its
// field — research simply skips it (see nextResearchTarget), and the only way
// to ever own it is trade, conquest or espionage. That is MOO1's rule and it
// is what makes tech trading matter.
const openAll = spec.traits.researchMult >= 2;
for (const t of rules.techList) {
if (t.tier === 0 || openAll) { available[t.id] = true; continue; }
const affinity = spec.techAffinity[t.field] ?? 1;
const chance = Math.max(0.3, Math.min(1, 0.55 * affinity + 0.2));
available[t.id] = rand(state) < chance;
}
return available;
}
export function createGame(rules, opts) {
const {
sizeId = 'medium', shapeId = 'spiral', seed = 1, difficultyId = 'normal',
speciesIds = ['human', 'kkrix', 'rrashaa'], humanIndex = 0,
} = 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,
events: [],
council: { nextTurn: rules.council.firstTurn, lastResult: null, 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: {},
beakers: {},
bc: rules.economy.startingBC,
leaders: [],
contacted: {},
treaties: {},
attitude: {},
pendingOffers: {},
explored: {},
spyPoints: 0,
totalPop: 0,
_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 colony = foundColony(rules, state, e, starIdx, 0, rules.economy.startingPop);
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;
}
export function foundColony(rules, state, e, starIdx, orbit, pop) {
const colony = {
id: state.nextColonyId += 1,
empireIdx: e,
starIdx,
orbit,
pop,
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: {},
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
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 tier in the field must be resolved — known, or rolled
// unavailable and therefore skipped.
for (const t of rules.techsByField[tech.field]) {
if (t.tier >= tech.tier) break;
if (!emp.known[t.id] && emp.available[t.id]) 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;
}
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]);
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;
// --- 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;
emp.bc = Math.max(0, emp.bc + trade - upkeep);
emp.lastIncome = trade - 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 steals a tech it could not research itself.
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');
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;
const targets = state.empires.filter((o) => o.alive && o.idx !== e
&& emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance');
if (!targets.length) return;
const target = targets[randInt(state, targets.length)];
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');
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, turn: state.turn });
target.attitude[e] = (target.attitude[e] ?? 0) - 12;
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 });
target.attitude[e] = (target.attitude[e] ?? 0) - 8;
}
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) {
if (fleet.toStar >= 0) return false;
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;
// 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.
export function resolveCombats(rules, state) {
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;
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 (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 round by round through
// VegaCombatView and then hand the outcome back — the interactive battle and
// auto-resolve therefore run the same engine and cannot diverge.
export function prepareBattleAt(rules, state, starIdx, a, b) {
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);
const defColony = colony && colony.empireIdx === defenderIdx ? colony : null;
if (!attacker.ships.length) return null;
if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null;
const battle = createBattle(rules, {
attacker,
defender,
colony: defColony ? { defenseHp: defColony.defenseHp, shieldBonus: 0 } : 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.
export function pendingBattlesFor(rules, state, e) {
const out = [];
const seen = new Set();
for (const f of state.fleets) {
if (f.starIdx < 0 || f.empireIdx !== e) continue;
if (seen.has(f.starIdx)) continue;
seen.add(f.starIdx);
const foes = new Set();
for (const g of state.fleets) {
if (g.starIdx === f.starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx);
}
const colony = colonyAt(state, f.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: f.starIdx, other });
}
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) {
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);
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, fromStarIdx, toStarIdx, amount) {
if (fromStarIdx === toStarIdx) return false;
const colony = colonyAt(state, fromStarIdx);
if (!colony || colony.empireIdx !== e) return false;
const dest = colonyAt(state, toStarIdx);
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).
const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
if (!canSendFleet(rules, state, probe, toStarIdx)) return false;
state.fleets.push({
id: state.nextFleetId += 1,
empireIdx: e,
starIdx: -1,
fromStar: fromStarIdx,
toStar: toStarIdx,
progress: 0,
total: parsecs(state.galaxy, fromStarIdx, toStarIdx),
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
});
colony.pop -= send;
pushEvent(state, {
type: 'populationSent', empire: e, starIdx: fromStarIdx, toStarIdx, 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;
const colony = 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;
}
// 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;
}
// 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.
export function bombard(rules, state, e, starIdx) {
const colony = colonyAt(state, starIdx);
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];
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;
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);
checkElimination(rules, state, owner);
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) {
const colony = colonyAt(state, starIdx);
if (!colony || colony.empireIdx === e) return null;
const troops = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
.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 attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0);
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) {
const colony = colonyAt(state, starIdx);
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);
// 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] = (state.empires[from].attitude[e] ?? 0) - 40;
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
checkElimination(rules, state, from);
} else {
pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn });
}
recomputeTotals(rules, state);
return result;
}
function checkElimination(rules, state, e) {
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, 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;
for (const voter of alive) {
const weight = voter.totalPop;
const own = candidates.find((c) => c.idx === voter.idx);
if (own) { votes[own.idx] += weight; 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; continue; }
votes[best.idx] += weight;
}
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) };
state.council.lastResult = result;
state.council.history.push(result);
state.council.nextTurn = state.turn + rules.council.interval;
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); }
export function endEmpireTurn(rules, state, e, { skipMove = false } = {}) {
if (state.empires[e].alive) {
if (!skipMove) moveFleets(rules, state, e);
resolveCombats(rules, state);
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;
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;
}
}
export function setResearchAlloc(rules, state, e, field, value) {
const emp = state.empires[e];
const fields = Object.keys(rules.techFields);
const v = Math.max(0, Math.min(1, value));
emp.alloc[field] = v;
const others = fields.filter((f) => f !== field);
const rest = 1 - v;
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;
}
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;
}
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;
if (emp.bc < leader.hireCost) return false;
emp.bc -= leader.hireCost;
emp.leaders.push({ leaderId, assignKind: null, assignId: -1 });
pushEvent(state, { type: 'leaderHired', empire: e, leaderId, 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;
}
// --------------------------------------------------------------------------
// 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;
for (const emp of state.empires) {
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
emp._designs = null; emp._designsAt = -1;
}
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);
}