// Master of Vega — the AI empire controller. Headless. // // One entry point, runAITurn(rules, state, e), structured as a fixed pipeline // so a soak failure can always be traced to a stage: // strategy -> research -> colonies (sliders + build queue) -> fleets // -> diplomacy -> leaders // // The AI plays by exactly the same rules as the human: it calls the same // engine functions, and gets no hidden information. Its only advantages come // from the difficulty multipliers in the rules file. import { empireColonies, empireFleets, colonyAt, reachableStars, canColonize, colonize, invade, sendFleet, canSendFleet, enqueue, setSlider, setResearchAlloc, colonyProduction, colonyFactoryCap, effectiveFactories, colonyDefenseCap, empireDesign, empireComponents, fleetPower, atWar, rand, nextResearchTarget, invasionForecast, bombard, } from './VegaLogic.js'; import { runDiplomacyTurn } from './VegaDiplomacy.js'; import { runLeaderTurn } from './VegaLeaders.js'; import { parsecs } from './VegaGalaxyGen.js'; // The warship the AI builds: best power-per-BC it can actually afford in a // reasonable number of turns. Left unchecked an AI will queue a battleship on // turn 30 and stall its economy for a century waiting for it. function preferredWarship(rules, state, e, budgetPerTurn) { const hulls = ['frigate', 'destroyer', 'cruiser', 'battleship']; let best = null; let bestScore = -Infinity; for (const h of hulls) { const d = empireDesign(rules, state, e, h); if (d.damage <= 0) continue; // Anything that takes more than ~15 turns to build is not a real option. if (d.cost > budgetPerTurn * 15) continue; const score = (d.hp + d.damage * 4) / d.cost; if (score > bestScore) { bestScore = score; best = d; } } return best ?? empireDesign(rules, state, e, 'frigate'); } function computeStrategy(rules, state, e) { const emp = state.empires[e]; const colonies = empireColonies(state, e); const reach = reachableStars(rules, state, e); // Where could we settle right now? const targets = []; for (const key of Object.keys(reach)) { const starIdx = Number(key); const star = state.galaxy.stars[starIdx]; if (!star.planets.length) continue; for (let orbit = 0; orbit < star.planets.length; orbit += 1) { if (!canColonize(rules, state, e, starIdx, orbit)) continue; const planet = star.planets[orbit]; const type = rules.planetTypes[planet.typeId]; const rich = rules.richness[planet.richId]?.industryMult ?? 1; // Prefer big, rich, mild worlds close to home. const home = colonies.length ? Math.min(...colonies.map((c) => parsecs(state.galaxy, c.starIdx, starIdx))) : 0; const score = planet.basePop * rich * (1 - 0.1 * type.hostility) - home * 2; targets.push({ starIdx, orbit, score }); } } targets.sort((a, b) => b.score - a.score); const enemies = state.empires.filter((o) => o.alive && o.idx !== e && atWar(state, e, o.idx)); let threat = 0; for (const o of enemies) { for (const f of empireFleets(state, o.idx)) threat = Math.max(threat, fleetPower(rules, state, f)); } const myFleet = empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0); let phase = 'develop'; if (enemies.length && (threat > myFleet * 0.6 || colonies.length > 2)) phase = 'war'; else if (targets.length > 0 && colonies.length < 10) phase = 'expand'; const income = colonies.reduce((t, c) => t + colonyProduction(rules, state, c), 0); return { colonies, reach, targets, enemies, threat, myFleet, phase, income }; } // -------------------------------------------------------------------------- function manageResearch(rules, state, e, strat) { const emp = state.empires[e]; const fields = Object.keys(rules.techFields); const w = {}; for (const f of fields) w[f] = 1; if (strat.phase === 'war') { w.weapons = 3; w.construction = 2.2; w.forcefields = 2; w.computers = 1.6; w.propulsion = 1; w.planetology = 0.6; } else if (strat.phase === 'expand') { w.propulsion = 2.6; w.planetology = 2.6; w.construction = 1.4; w.computers = 1; w.weapons = 0.8; w.forcefields = 0.7; } else { w.computers = 1.6; w.construction = 1.6; w.planetology = 1.4; w.propulsion = 1.2; w.weapons = 1; w.forcefields = 1; } // No point pouring beakers into a field where everything left is either known // or was rolled unavailable — that research would vanish. for (const f of fields) { if (!emp.researching[f] && !nextResearchTarget(rules, state, e, f)) w[f] = 0; } const total = fields.reduce((t, f) => t + w[f], 0); if (total <= 0) return; for (const f of fields) setResearchAlloc(rules, state, e, f, w[f] / total); } // -------------------------------------------------------------------------- const BUILDING_PRIORITY = [ 'automatedfactory', 'researchlab', 'pollutionprocessor', 'spaceport', 'cloningcenter', 'robominers', 'stockexchange', 'supercomputer', 'missilebase', 'terraformingplant', 'holosimulator', 'groundbattery', 'planetaryshield', 'soilenrichmentfac', 'spycenter', 'artemisnet', ]; function manageColony(rules, state, e, colony, strat) { const emp = state.empires[e]; const spec = rules.species[emp.speciesId]; const prod = colonyProduction(rules, state, colony); const comps = empireComponents(rules, state, e); // Ecology first: work out what cleanup actually costs and fund exactly that. // Under-funding steals from every other channel, over-funding is dead money. const wasteGen = effectiveFactories(rules, state, colony) * rules.economy.wastePerFactory * spec.traits.ecologyMult; const cleanupCost = (colony.waste + wasteGen) * rules.economy.wasteCleanupCost * comps.wasteMult; const ecoNeed = prod > 0 ? Math.min(0.6, cleanupCost / prod) : 0; const roomForFactories = colony.factories < colonyFactoryCap(rules, state, colony); const roomForDefense = colony.defenseHp < colonyDefenseCap(rules, state, colony); const frontier = strat.enemies.length > 0; let ships = 0.15; let defense = 0; let industry = roomForFactories ? 0.45 : 0.1; let research = 0.25; if (strat.phase === 'war') { ships = 0.45; defense = roomForDefense ? 0.15 : 0; industry = roomForFactories ? 0.25 : 0.05; research = 0.15; } else if (strat.phase === 'expand') { ships = 0.3; industry = roomForFactories ? 0.4 : 0.1; research = 0.25; } else if (frontier && roomForDefense) { defense = 0.1; } const rest = Math.max(0, 1 - ecoNeed); const sum = ships + defense + industry + research; const norm = sum > 0 ? rest / sum : 0; // setSlider redistributes the remainder across the others, so ecology is set // last and the rest are written straight in. colony.sliders.ecology = ecoNeed; colony.sliders.ships = ships * norm; colony.sliders.defense = defense * norm; colony.sliders.industry = industry * norm; colony.sliders.research = research * norm; // --- build queue if (colony.queue.length >= 3) return; const budget = Math.max(1, prod * (colony.sliders.ships || 0.1)); // Colony ships, one per outstanding target, capped so expansion cannot eat // the entire economy. const colonyShipsOut = empireFleets(state, e) .reduce((t, f) => t + f.ships.filter((s) => s.hullId === 'colonyship').reduce((n, s) => n + s.count, 0), 0); const queuedColonyShips = state.colonies .filter((c) => c.empireIdx === e) .reduce((t, c) => t + c.queue.filter((q) => q.id === 'colonyship').length, 0); const wantColony = Math.min(3, strat.targets.length) - colonyShipsOut - queuedColonyShips; if (wantColony > 0 && strat.phase !== 'war') { enqueue(rules, state, colony, 'ship', 'colonyship'); return; } // Warships when threatened, or a standing patrol once developed. const warship = preferredWarship(rules, state, e, budget); // At war, keep building: a fleet that stops at parity can never break // through, and the enemy is building too. const needFleet = strat.phase === 'war' ? strat.myFleet < Math.max(strat.threat * 4, prod * 30) : strat.myFleet < 400 + state.turn * 4; if (needFleet && warship) { enqueue(rules, state, colony, 'ship', warship.hullId); return; } // A war fleet with no marines can bombard forever and take nothing. Keep a // standing invasion capability whenever we are actually at war. if (strat.phase === 'war') { const transports = empireFleets(state, e) .reduce((t, f) => t + f.ships.filter((s) => s.hullId === 'transport').reduce((n, s) => n + s.count, 0), 0); // Enough marines to actually carry a defended world, not a token squad. let wanted = 10; for (const enemy of strat.enemies) { for (const c of empireColonies(state, enemy.idx)) { wanted = Math.max(wanted, Math.ceil((c.pop / 8 + 6) * 1.6 / (rules.hulls.transport.troops ?? 4))); } } if (transports < Math.min(wanted, 40)) { enqueue(rules, state, colony, 'ship', 'transport'); return; } } // Buildings, cheapest useful thing first. for (const bid of BUILDING_PRIORITY) { const b = rules.buildings[bid]; if (!b) continue; if (colony.buildings.includes(bid)) continue; if (b.prereq && !emp.known[b.prereq]) continue; if (colony.queue.some((q) => q.kind === 'building' && q.id === bid)) continue; // Do not saddle a tiny outpost with upkeep it cannot carry. if (b.cost > prod * 25) continue; enqueue(rules, state, colony, 'building', bid); return; } // Nothing else worth doing — extend range from the frontier. if (!colony.buildings.includes('starbase') && strat.phase !== 'war' && prod > 20) { enqueue(rules, state, colony, 'ship', 'starbase'); } } // -------------------------------------------------------------------------- function manageFleets(rules, state, e, strat) { const emp = state.empires[e]; const reach = strat.reach; const fleets = empireFleets(state, e).filter((f) => f.starIdx >= 0 && f.toStar < 0); const claimed = new Set(); for (const fleet of fleets) { const hasColonyShip = fleet.ships.some((s) => s.hullId === 'colonyship' && s.count > 0); const hasTransport = fleet.ships.some((s) => s.hullId === 'transport' && s.count > 0); const isScout = fleet.ships.every((s) => s.hullId === 'scout'); const power = fleetPower(rules, state, fleet); // 1. Settle where we stand, if we can. if (hasColonyShip) { const star = state.galaxy.stars[fleet.starIdx]; let done = false; for (let orbit = 0; orbit < star.planets.length; orbit += 1) { if (canColonize(rules, state, e, fleet.starIdx, orbit)) { if (colonize(rules, state, e, fleet.starIdx, orbit)) { done = true; break; } } } if (done) continue; const target = strat.targets.find((t) => !claimed.has(t.starIdx)); if (target && canSendFleet(rules, state, fleet, target.starIdx)) { claimed.add(target.starIdx); sendFleet(rules, state, fleet, target.starIdx); continue; } } // 2a. Bombard whatever we are sitting on top of. Softening the colony makes // the subsequent landing viable, and against a cornered empire that will // never yield clean orbit it is the only way to finish the war at all. { const colony = colonyAt(state, fleet.starIdx); if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && power > 0) { // Bomb only when we cannot take the world intact. A captured colony is // worth far more than a dead one, and bombarding unconditionally turned // every war into scorched earth — 805 worlds burned against 40 taken. const forecast = invasionForecast(rules, state, e, fleet.starIdx); if (!forecast || !forecast.favourable) bombard(rules, state, e, fleet.starIdx); } } // 2b. Invade a cleared colony, or move up to one our warships are besieging. if (hasTransport) { const colony = colonyAt(state, fleet.starIdx); if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx)) { // Only land when the marines can actually carry the world. A failed // landing costs the whole transport wave for nothing, so waiting for a // second wave beats throwing away the first. const forecast = invasionForecast(rules, state, e, fleet.starIdx); if (forecast?.favourable && invade(rules, state, e, fleet.starIdx)) continue; } // Follow the siege: head for an enemy colony where we already hold orbit. let siege = null; let siegeD = Infinity; for (const enemy of strat.enemies) { for (const c of empireColonies(state, enemy.idx)) { if (!reach[c.starIdx]) continue; const mine = state.fleets.some((f) => f.starIdx === c.starIdx && f.empireIdx === e && fleetPower(rules, state, f) > 0); if (!mine) continue; const d = parsecs(state.galaxy, fleet.starIdx, c.starIdx); if (d < siegeD) { siegeD = d; siege = c; } } } if (siege && siege.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, siege.starIdx)) { sendFleet(rules, state, fleet, siege.starIdx); continue; } // No siege to join yet: fall in with the main battle fleet so the marines // travel WITH the warships and are already in orbit the turn after it // wins. Transports carry no guns, so without this they never satisfy the // `power > 0` test below, never move, and spend the entire war parked // over the homeworld — measured at 3495 idle observations against 12 in // transit, and it is why no colony was ever taken. if (strat.phase === 'war') { let escort = null; let escortPower = 0; for (const f of empireFleets(state, e)) { if (f.id === fleet.id || f.starIdx < 0) continue; const p = fleetPower(rules, state, f); if (p > escortPower) { escortPower = p; escort = f; } } if (escort && escort.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, escort.starIdx)) { sendFleet(rules, state, fleet, escort.starIdx); continue; } } } // 3. Scouts chart the dark. if (isScout) { const unexplored = Object.keys(reach) .map(Number) .filter((i) => !emp.explored[i]); if (unexplored.length) { const nearest = unexplored.reduce((best, i) => ( parsecs(state.galaxy, fleet.starIdx, i) < parsecs(state.galaxy, fleet.starIdx, best) ? i : best ), unexplored[0]); if (canSendFleet(rules, state, fleet, nearest)) { sendFleet(rules, state, fleet, nearest); continue; } } } if (power <= 0) continue; // 4. Warships: defend a threatened colony, else press the attack. if (strat.phase === 'war' && strat.enemies.length) { // Anything of ours under threat and undefended comes first. let rescue = null; let rescueD = Infinity; for (const c of strat.colonies) { const hostile = state.fleets.some((f) => f.starIdx === c.starIdx && f.empireIdx !== e && atWar(state, e, f.empireIdx)); if (!hostile) continue; const d = parsecs(state.galaxy, fleet.starIdx, c.starIdx); if (d < rescueD) { rescueD = d; rescue = c; } } if (rescue && rescue.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, rescue.starIdx)) { sendFleet(rules, state, fleet, rescue.starIdx); continue; } // Otherwise hit the weakest enemy colony we can reach. let best = null; let bestScore = -Infinity; for (const enemy of strat.enemies) { for (const c of empireColonies(state, enemy.idx)) { if (!reach[c.starIdx]) continue; // Planetary defences count for less than live warships: they cannot // chase, cannot reinforce, and can be ground down across several // turns of siege. Weighting them one-for-one against fleet power made // every defended colony look unassailable, so the war fleet parked at // the frontier and no war ever advanced. const guard = state.fleets .filter((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx) .reduce((t, f) => t + fleetPower(rules, state, f), 0) + c.defenseHp * 0.4; // Attack at rough parity. Demanding a clear local edge produced a // permanent phoney war: both sides built to the same strength, each // decided it was not quite winning enough, and nothing ever moved for // seven hundred turns. if (power < guard) continue; // Concentrate on whoever is closest to collapse. Spreading pressure // evenly across every rival keeps them all alive indefinitely; a war // is only won by finishing somebody off. const enemyColonies = empireColonies(state, enemy.idx).length; const finisher = enemyColonies <= 2 ? 60 : 0; const score = c.pop + finisher - enemyColonies * 4 - guard * 0.05 - parsecs(state.galaxy, fleet.starIdx, c.starIdx); if (score > bestScore) { bestScore = score; best = c; } } } if (best && best.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, best.starIdx)) { sendFleet(rules, state, fleet, best.starIdx); continue; } } // 5. At war with nothing it can take alone, a fleet RALLIES instead of // sitting still. Sending every squadron off independently means each one // meets the enemy's whole navy on its own and dies; concentrating turns a // stalemate into a breakthrough. Fleets sharing a system merge next turn, // so this compounds into one hammer. if (strat.phase === 'war' && strat.enemies.length) { let rally = null; let rallyPower = power; for (const f of empireFleets(state, e)) { if (f.id === fleet.id || f.starIdx < 0) continue; const p = fleetPower(rules, state, f); if (p > rallyPower) { rallyPower = p; rally = f; } } if (rally && rally.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, rally.starIdx)) { sendFleet(rules, state, fleet, rally.starIdx); continue; } // No bigger friend: gather at the colony nearest the enemy front. let staging = null; let stagingD = Infinity; for (const c of strat.colonies) { for (const enemy of strat.enemies) { for (const ec of empireColonies(state, enemy.idx)) { const d = parsecs(state.galaxy, c.starIdx, ec.starIdx); if (d < stagingD) { stagingD = d; staging = c; } } } } if (staging && staging.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, staging.starIdx)) { sendFleet(rules, state, fleet, staging.starIdx); } continue; } // 6. In peacetime, idle warships fall back to the most valuable colony. const home = strat.colonies.slice().sort((a, b) => b.pop - a.pop)[0]; if (home && fleet.starIdx !== home.starIdx && canSendFleet(rules, state, fleet, home.starIdx) && rand(state) < 0.25) { sendFleet(rules, state, fleet, home.starIdx); } } } // -------------------------------------------------------------------------- export function runAITurn(rules, state, e) { const emp = state.empires[e]; if (!emp.alive) return; const strat = computeStrategy(rules, state, e); manageResearch(rules, state, e, strat); for (const colony of strat.colonies) manageColony(rules, state, e, colony, strat); manageFleets(rules, state, e, strat); runDiplomacyTurn(rules, state, e); runLeaderTurn(rules, state, e); }