// 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); }