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