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

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