436 lines
19 KiB
JavaScript
436 lines
19 KiB
JavaScript
// 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);
|
|
}
|