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

1522 lines
59 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'));
}
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) {
const planet = state.galaxy.stars[starIdx]?.planets?.[orbit];
if (!planet) return false;
const type = rules.planetTypes[planet.typeId];
if (!type.colonizable) return false;
if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) 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: {},
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;
// Neighbours are charted from the start — nobody begins truly blind.
for (let i = 0; i < state.galaxy.stars.length; i += 1) {
if (parsecs(state.galaxy, starIdx, i) <= (rules.economy.baseFuelRange ?? 4)) {
state.empires[e].explored[i] = true;
}
}
// 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);
updateContacts(rules, state);
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));
}
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;
state.empires[e].explored[f.starIdx] = true;
pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn });
makeContactAt(rules, state, e, f.starIdx);
}
}
}
// Empires also notice each other simply by living nearby — you do not need to
// fly a ship into someone's home system to know they are there. Contact used to
// require exactly that, so in most galaxies nobody ever met anybody, which meant
// no diplomacy, no wars, and every game grinding out to the turn cap.
export function updateContacts(rules, state) {
const range = rules.economy.contactRange ?? 12;
const alive = state.empires.filter((e) => e.alive);
for (let i = 0; i < alive.length; i += 1) {
for (let j = i + 1; j < alive.length; j += 1) {
const a = alive[i];
const b = alive[j];
if (a.contacted[b.idx]) continue;
let met = false;
for (const ca of empireColonies(state, a.idx)) {
for (const cb of empireColonies(state, b.idx)) {
if (parsecs(state.galaxy, ca.starIdx, cb.starIdx) <= range) { met = true; break; }
}
if (met) break;
}
if (!met) continue;
a.contacted[b.idx] = true;
b.contacted[a.idx] = true;
pushEvent(state, { type: 'contact', empire: a.idx, other: b.idx, starIdx: -1, turn: state.turn });
}
}
}
function makeContactAt(rules, state, e, starIdx) {
const others = new Set();
for (const c of coloniesAt(state, starIdx)) if (c.empireIdx !== e) others.add(c.empireIdx);
for (const f of fleetsAt(state, starIdx)) if (f.empireIdx !== e) others.add(f.empireIdx);
for (const o of others) {
if (state.empires[e].contacted[o]) continue;
state.empires[e].contacted[o] = true;
state.empires[o].contacted[e] = true;
pushEvent(state, { type: 'contact', empire: e, other: o, 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);
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;
}
// 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;
}
export function dequeue(rules, state, colony, index) {
if (index < 0 || index >= colony.queue.length) return false;
colony.queue.splice(index, 1);
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);
}