fertig-classic-games/src/games/civilization/CivilizationAI.js

792 lines
34 KiB
JavaScript

// Civilization — AI civ controller. Headless (no Phaser).
//
// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick,
// diplomacy, research, city builds, then unit orders. respondToProposal
// answers human (or other-AI) diplomacy. AI-initiated approaches toward the
// human (treaty proposals, favours, demands) are queued on
// state.pendingRequests for the scene to present as an audience at the start
// of the human turn — see CivilizationDiplomacy.js.
import {
rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt,
civUnits, civCities, cityById, knownCount, availableTechs, availableUnits,
availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild,
tryMove, disembark, findPath, startWork, canWork, canEstablishRoute,
establishTradeRoute, resolveAttack, attackerStrength, defenderStrength,
pickDefender, launchSpaceship, canPropose, declareWar,
exchangeTechs, civPower, cityYields, buildCost,
setResearch, startRevolution,
} from './CivilizationLogic.js';
import {
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
} from './CivilizationDiplomacy.js';
import {
runBarbarianTurn, barbarianThreatFor, barbarianCities, currentLeader,
} from './CivilizationBarbarians.js';
const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops
const LEADER_HUNT_RANGE = 8; // how far a unit will detour to chase a ransom
export function runAITurn(rules, state, civIdx) {
const civ = state.civs[civIdx];
if (!civ.alive || state.over) return;
// Barbarians share the civ turn loop but none of the empire management —
// no research, no builds, no diplomacy. Dispatching here means the scene's
// stepCiv and the verify soak both drive them with no extra wiring.
if (civ.barbarian) { runBarbarianTurn(rules, state); return; }
const strategy = computeStrategy(rules, state, civIdx);
doDiplomacy(rules, state, civIdx, strategy);
doResearch(rules, state, civIdx, strategy);
doGovernment(rules, state, civIdx);
for (const city of civCities(state, civIdx)) {
manageCityBuild(rules, state, civIdx, city, strategy);
}
doUnits(rules, state, civIdx, strategy);
// Launch when ready unless conquest is already in hand.
const ship = civ.spaceship;
if (!ship.launched
&& ship.structural >= rules.spaceship.structuralNeeded
&& ship.component >= rules.spaceship.componentsNeeded
&& ship.module >= rules.spaceship.modulesNeeded) {
launchSpaceship(rules, state, civ);
}
}
// ---------------------------------------------------------------------------
// Strategy
export function computeStrategy(rules, state, civIdx) {
const civ = state.civs[civIdx];
const diff = rules.difficulties[state.difficultyId];
const myCities = civCities(state, civIdx);
const myPower = civPower(rules, state, civIdx);
// Barbarians are deliberately NOT in atWarWith. `phase` flips to 'war' the
// moment this is non-empty, and since everyone is permanently at war with
// barbarians that would pin every AI in war phase for the whole game: no
// expansion, no spaceship parts, and no peacetime scouting (that slice is
// gated on atWarWith being empty), so AIs would stop exploring entirely.
// Barbarian pressure arrives through `barbarianThreat` below instead.
const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx && !c.barbarian
&& civ.relations[c.id] === 'war').map((c) => c.id);
const techTotal = rules.techList.filter((t) => !t.repeatable).length;
const techFrac = knownCount(civ) / techTotal;
const wantsSpace = civ.known.spaceflight || techFrac >= 0.8;
const settlerCount = civUnits(state, civIdx)
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
const landTiles = state.world.cols * state.world.rows * (state.world.landFraction ?? 0.3);
const targetCities = Math.max(5, Math.round(landTiles / 40));
const canExpand = myCities.length < targetCities && settlerCount < 3;
let phase = 'develop';
if (atWarWith.length) phase = 'war';
else if (wantsSpace) phase = 'space';
else if (canExpand || myCities.length === 0) phase = 'expand';
return {
phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac,
barbarianThreat: barbarianThreatFor(rules, state, civIdx),
};
}
// ---------------------------------------------------------------------------
// Diplomacy
function doDiplomacy(rules, state, civIdx, strategy) {
const civ = state.civs[civIdx];
checkPledges(rules, state, civIdx);
for (const other of state.civs) {
if (other.id === civIdx || !other.alive) continue;
// No audiences with raiders. Without this the `rel === 'war'` branch below
// would have every AI proposing a ceasefire to the barbarians every turn.
if (other.barbarian) continue;
if (civ.relations[other.id] === 'nocontact') continue;
// A leader worn down by refused requests tears up its treaties first; if
// the grudge keeps climbing, the hostility checks below finish the job.
// This can change the relation, so read it afterwards.
escalateFrustration(rules, state, civIdx, other.id);
const rel = civ.relations[other.id];
const attitude = civ.attitude[other.id] ?? 0;
const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id));
if (rel === 'war') {
// Sue for peace when clearly losing.
if (powerRatio < 0.6 && canPropose(state, civIdx, other.id, 'ceasefire')) {
proposeOrQueue(rules, state, civIdx, other.id, 'ceasefire');
}
continue;
}
// Escalate to war: hostile attitude + power advantage + aggression roll,
// opportunism against a much weaker neighbour, or (rarely) a true sneak
// attack on a treaty partner who has fallen far behind.
const sneakBar = rel === 'peace' || rel === 'alliance' ? -70 : -10;
const hostile = attitude <= sneakBar && powerRatio > 1.15;
const opportunist = rel === 'contact' && powerRatio > 1.6 && attitude < 40;
const sneak = (rel === 'peace') && powerRatio > 2.5 && attitude < 0;
if ((hostile || opportunist) && rand(state) < strategy.aggression * 0.3) {
declareWar(rules, state, civIdx, other.id);
continue;
}
if (sneak && rand(state) < strategy.aggression * 0.1) {
declareWar(rules, state, civIdx, other.id);
continue;
}
// Peace-seeking & alliances with actual friends only — a world of
// reflexive peace treaties stalls the game forever.
if (rel === 'ceasefire' && attitude > 0) proposeOrQueue(rules, state, civIdx, other.id, 'peace');
else if (rel === 'contact' && attitude > 30) proposeOrQueue(rules, state, civIdx, other.id, 'peace');
else if (rel === 'peace' && attitude > 50 && strategy.atWarWith.length === 0
&& sharedEnemy(state, civIdx, other.id)) {
proposeOrQueue(rules, state, civIdx, other.id, 'alliance');
}
// Fair tech trades with non-hostile AIs. The human gets asked instead, via
// a techTrade request below, so the offer goes through the audience UI.
if (!other.human && attitude > 0 && rand(state) < 0.25) {
tryFairExchange(rules, state, civIdx, other.id);
}
// Leader-initiated approaches: favours, ultimatums, demands, gifts.
if (considerGift(rules, state, civIdx, other.id)) continue;
const req = considerRequest(rules, state, civIdx, other.id, strategy);
if (req) issueRequest(rules, state, req);
}
}
// Routes a request at its recipient: the human sees it as an audience at the
// start of their turn, another AI answers it on the spot. Cooldowns are set
// when the request is ISSUED (inside resolveRequest for AI targets, here for
// human ones) so an unanswered request can't be re-asked next turn.
function issueRequest(rules, state, req) {
if (state.civs[req.to].human) {
state.pendingRequests ??= [];
// One audience is drained per human turn; a hard cap stops a crowded world
// from queueing up a backlog the player can never work through.
if (state.pendingRequests.length >= 4) return;
if (state.pendingRequests.some((p) => p.from === req.from && p.kind === req.kind)) return;
setCooldown(rules, state, req.from, req.to, req.kind);
state.pendingRequests.push(req);
return;
}
// Treaty kinds keep their existing AI answer; the rest use aiWouldAccept.
const TREATY = ['ceasefire', 'peace', 'alliance'];
const accepted = TREATY.includes(req.kind)
? respondToProposal(rules, state, req.to, req.from, req.kind)
: aiWouldAccept(rules, state, req);
resolveRequest(rules, state, req, accepted);
}
function sharedEnemy(state, a, b) {
// Barbarians excluded for the same reason as in updateAttitudes: everyone is
// at war with them, so they'd make every pair of civs "natural allies".
return state.civs.some((c) => c.alive && c.id !== a && c.id !== b && !c.barbarian
&& state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war');
}
// Treaty proposals go through the same request pipeline as favours and demands
// so they share the audience UI, the cooldowns and the per-leader gap.
function proposeOrQueue(rules, state, fromIdx, toIdx, kind) {
const req = makeTreatyRequest(rules, state, fromIdx, toIdx, kind);
if (req) issueRequest(rules, state, req);
}
export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) {
const civ = state.civs[aiIdx];
const attitude = civ.attitude[fromIdx] ?? 0;
const powerRatio = civPower(rules, state, aiIdx)
/ Math.max(1, civPower(rules, state, fromIdx));
switch (kind) {
case 'ceasefire':
// Winners press on; only accept when not dominant or genuinely friendly.
return powerRatio < 1.1 || attitude > 0;
case 'peace':
return powerRatio < 0.8 || attitude > 20;
case 'alliance':
return attitude > 40 && (sharedEnemy(state, aiIdx, fromIdx) || attitude > 65);
case 'exchange': {
const { giveId, getId } = payload; // from `fromIdx`'s perspective
if (!giveId || !getId) return false;
const rankGive = rules.techRank[giveId] ?? 0;
const rankGet = rules.techRank[getId] ?? 0;
return attitude > -10 && rankGive >= rankGet - 1;
}
case 'tribute':
return powerRatio < 0.6 && (payload.amount ?? 0) <= civ.gold * 0.25;
default:
return false;
}
}
function tryFairExchange(rules, state, a, b) {
const trade = pickFairTrade(rules, state, a, b);
if (trade) exchangeTechs(rules, state, a, b, trade.giveId, trade.getId);
}
// ---------------------------------------------------------------------------
// Research & government
const SPACE_CHAIN = ['spaceflight', 'plastics', 'superconductor', 'fusionpower'];
function doResearch(rules, state, civIdx, strategy) {
const civ = state.civs[civIdx];
if (civ.researching) return;
const options = availableTechs(rules, civ);
if (!options.length) return;
let best = null;
let bestScore = -Infinity;
for (const t of options) {
const g = rules.techGates[t.id];
let score = rand(state) * 4; // jitter
score += g.prereqOf.length * 2;
const gatesUnits = g.units.length > 0;
const gatesEcon = g.buildings.some((b) => ['science', 'gold', 'shields', 'granary', 'sizecap']
.includes(rules.buildings[b].effect));
if (strategy.phase === 'war' && gatesUnits) score += 8;
if (strategy.phase !== 'war' && gatesEcon) score += 6;
if (g.governments.length && strategy.phase !== 'war') score += 5;
if (strategy.phase === 'space' && (SPACE_CHAIN.includes(t.id)
|| g.prereqOf.some((p) => SPACE_CHAIN.includes(p)))) score += 12;
if (t.repeatable) score -= 20;
if (score > bestScore) { bestScore = score; best = t; }
}
if (best) setResearch(rules, state, civ, best.id);
}
function doGovernment(rules, state, civIdx) {
const civ = state.civs[civIdx];
if (civ.government === 'anarchy') return;
// Simple ladder: despotism -> monarchy -> republic (peace) / communism (war-heavy).
const wants = civ.known.democracy && atPeace(state, civIdx) ? 'democracy'
: civ.known.therepublic && atPeace(state, civIdx) ? 'republic'
: civ.known.communism && !atPeace(state, civIdx) ? 'communism'
: civ.known.monarchy ? 'monarchy' : null;
if (wants && wants !== civ.government
&& govRank(wants) > govRank(civ.government)) {
startRevolution(rules, state, civ, wants);
}
}
// Peace means peace with other PLAYERS. Barbarians are permanently at war with
// everyone, so reading the raw relations map here would make every civ
// eternally "at war": no AI would ever adopt Republic or Democracy (both gated
// on peace) and they'd all beeline Communism instead.
function atPeace(state, civIdx) {
return !state.civs.some((c) => !c.barbarian
&& state.civs[civIdx].relations[c.id] === 'war');
}
function govRank(id) {
return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0;
}
// ---------------------------------------------------------------------------
// City builds
const DEVELOP_CHAIN = ['granary', 'library', 'marketplace', 'barracks', 'aqueduct', 'harbor',
'university', 'bank', 'courthouse', 'sewersystem', 'factory', 'powerplant', 'stockexchange',
'superhighways', 'researchlab', 'supermarket', 'mfgplant'];
function manageCityBuild(rules, state, civIdx, city, strategy) {
const civ = state.civs[civIdx];
const def = rules.units[city.build?.id];
const midBuild = (city.build.type === 'unit' || city.build.type === 'building')
&& city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9;
const defenders = unitsAt(state, city.x, city.y)
.filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land'
&& !rules.units[u.type].flags.includes('noncombat'));
// Emergency: garrison first, buy it when the enemy is at the gate. Raiders
// at the walls call for a second defender without flipping the whole civ
// into war phase (see computeStrategy). The rush-buy check below needs no
// barbarian special-case — it already filters on relations === 'war'.
const wantGarrison = strategy.phase === 'war' || strategy.barbarianThreat >= 2 ? 2 : 1;
if (defenders.length < wantGarrison) {
const best = bestDefender(rules, state, civ, city);
if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id);
const enemyNear = state.units.some((u) => u.civ !== civIdx
&& state.civs[civIdx].relations[u.civ] === 'war'
&& !rules.units[u.type].flags.includes('noncombat')
&& cheb(u.x, u.y, city.x, city.y) <= 3);
if (defenders.length === 0 && enemyNear && civ.gold > buyCost(rules, city) + 50) {
buyBuild(rules, state, city);
}
return;
}
if (midBuild && city.build.type === 'unit' && def?.flags.includes('spaceship')) return;
if (midBuild && rand(state) < 0.7) return; // usually let builds finish
// Spaceship race: pour high-shield cities into parts.
if (strategy.phase === 'space') {
const parts = availableUnits(rules, state, civ, city)
.filter((u) => u.flags.includes('spaceship'))
.sort((a, b) => a.cost - b.cost);
if (parts.length) { setBuild(rules, state, city, 'unit', parts[0].id); return; }
}
// Expansion: settlers from cities that can spare the head.
if (strategy.phase === 'expand' && city.size >= 2) {
const settlerType = civ.known.explosives ? 'engineers' : 'settlers';
const settlersOut = civUnits(state, civIdx)
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
if (settlersOut < 3 && rand(state) < 0.7) {
setBuild(rules, state, city, 'unit', 'settlers');
return;
}
if (settlerType === 'engineers' && !civUnits(state, civIdx).some((u) => u.type === 'engineers')) {
setBuild(rules, state, city, 'unit', 'engineers');
return;
}
}
// War: attackers with a side of siege.
if (strategy.phase === 'war') {
const units = availableUnits(rules, state, civ, city)
.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship'));
if (units.length) {
const attackers = units.sort((a, b) => b.attack - a.attack);
const pick = rand(state) < 0.3
? attackers.find((u) => u.attack >= 6 && u.defense <= 2) ?? attackers[0]
: attackers[0];
setBuild(rules, state, city, 'unit', pick.id);
return;
}
}
// Keep a worker corps alive after expansion: roads/irrigation are the whole
// economy, and settlers all get consumed founding cities.
const workers = civUnits(state, civIdx)
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
const wantWorkers = Math.min(3, Math.max(1, Math.ceil(civCities(state, civIdx).length / 2)));
if (workers < wantWorkers && city.size >= 2 && rand(state) < 0.5) {
setBuild(rules, state, city, 'unit', civ.known.explosives ? 'engineers' : 'settlers');
return;
}
// Develop: walk the improvement chain; caravans for big trade cities.
const avail = availableBuildings(rules, state, civ, city);
const yields = cityYields(rules, state, city);
if (yields.netTrade >= 8 && city.routes.length < 2 && civ.known.trade && rand(state) < 0.3) {
const cvType = civ.known.thecorporation ? 'freight' : 'caravan';
setBuild(rules, state, city, 'unit', cvType);
return;
}
// Corruption triage: distant cities bleed trade to corruption and shields
// to waste — when the combined loss is worth a building's upkeep several
// times over, jump the chain straight to a courthouse (halves both).
if ((yields.corruption + yields.waste) >= 4 && avail.some((b) => b.id === 'courthouse')) {
setBuild(rules, state, city, 'building', 'courthouse');
return;
}
for (const id of DEVELOP_CHAIN) {
if (avail.some((b) => b.id === id)) {
// Skip aqueduct/sewer until the city is close to its cap.
if (id === 'aqueduct' && city.size < 6) continue;
if (id === 'sewersystem' && city.size < 10) continue;
setBuild(rules, state, city, 'building', id);
return;
}
}
// Nothing left to build or develop — convert shields to gold or food
// instead of stockpiling redundant defenders.
const preferFood = yields.foodSurplus < 3;
setBuild(rules, state, city, preferFood ? 'food' : 'gold', preferFood ? 'publicworks' : 'coinage');
}
function bestDefender(rules, state, civ, city) {
return availableUnits(rules, state, civ, city)
.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship'))
.sort((a, b) => b.defense - a.defense || a.cost - b.cost)[0] ?? null;
}
// ---------------------------------------------------------------------------
// Units
function doUnits(rules, state, civIdx, strategy) {
const civ = state.civs[civIdx];
for (const unit of [...civUnits(state, civIdx)]) {
if (!state.units.includes(unit) || state.over) continue;
if (unit.carriedBy) { handleCargo(rules, state, unit, strategy); continue; }
if (unit.order?.kind === 'work') continue;
const def = rules.units[unit.type];
if (def.domain === 'project') continue;
let steps = 0;
while (unit.mp > 0 && steps < MAX_UNIT_STEPS && state.units.includes(unit) && !state.over) {
const acted = stepUnit(rules, state, civIdx, unit, strategy);
steps += 1;
if (!acted) break;
}
if (state.units.includes(unit) && !unit.moved && !unit.fortified && def.defense > 0
&& cityAt(state, unit.x, unit.y)) {
unit.fortified = true;
}
}
}
function stepUnit(rules, state, civIdx, unit, strategy) {
const def = rules.units[unit.type];
if (def.flags.includes('settler')) return stepSettler(rules, state, civIdx, unit, strategy);
if (def.flags.includes('caravan')) return stepCaravan(rules, state, civIdx, unit);
if (def.flags.includes('ignoreterrain') && def.attack === 0) return stepExplorer(rules, state, civIdx, unit);
if (def.domain === 'sea') return stepShip(rules, state, civIdx, unit, strategy);
if (def.domain === 'air') { unit.mp = 0; return false; } // AI keeps air home (v1)
return stepMilitary(rules, state, civIdx, unit, strategy);
}
function moveToward(rules, state, unit, tx, ty) {
// Pathfind once per decision, then walk the whole path while movement
// lasts — re-planning every tile step dominates AI turn time otherwise.
const path = findPath(rules, state, unit, tx, ty);
if (!path || !path.length) return false;
let progressed = false;
for (const [nx, ny] of path) {
if (unit.mp <= 0 || !state.units.includes(unit) || state.over) break;
const out = tryMove(rules, state, unit, Math.sign(nx - unit.x), Math.sign(ny - unit.y));
if (out.result !== 'moved' && out.result !== 'boarded' && out.result !== 'captured') break;
progressed = true;
if (out.result !== 'moved') break; // boarded/captured ends the walk
if (out.hut) break; // hut outcomes can change the situation
}
return progressed;
}
function stepSettler(rules, state, civIdx, unit, strategy) {
const civ = state.civs[civIdx];
// Improve home turf when expansion is done (or this is an engineer).
const wantsFound = strategy.phase === 'expand' || civCities(state, civIdx).length === 0;
if (!wantsFound || rules.units[unit.type].flags.includes('engineer')) {
return stepWorker(rules, state, civIdx, unit);
}
const best = bestCitySite(rules, state, civIdx, unit);
if (!best) return stepWorker(rules, state, civIdx, unit);
if (best.x === unit.x && best.y === unit.y && canFoundCity(rules, state, unit.x, unit.y)) {
foundCity(rules, state, unit);
return false;
}
return moveToward(rules, state, unit, best.x, best.y);
}
function aiCitySiteScore(rules, world, x, y, covered) {
// Score the full working radius (Chebyshev distance <= 2), but only count
// tiles that aren't already within range of another city.
const { cols, rows } = world;
let q = 0;
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
if (covered.has(nx * 1000 + ny)) continue; // tile already covered by another city
const terr = rules.terrainList[world.terrain[ny * cols + nx]];
if (terr.water || terr.id === 'mountains' || terr.id === 'glacier') continue;
const spec = world.special[ny * cols + nx] >= 0 ? rules.specialList[world.special[ny * cols + nx]] : null;
const food = spec ? spec.food : terr.food;
const shield = spec ? spec.shield : terr.shield;
const trade = spec ? spec.trade : terr.trade;
q += food * 3 + shield * 2 + trade;
}
}
return q;
}
export function bestCitySite(rules, state, civIdx, unit) {
const { world } = state;
const myCities = civCities(state, civIdx);
let best = null;
let bestScore = -Infinity;
const R = 12; // search wider so distant good sites are found
// Pre-compute which tiles are already within working radius of own cities
const covered = new Set();
for (const city of myCities) {
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const cx = city.x + dx;
const cy = city.y + dy;
if (cx >= 0 && cy >= 0 && cx < world.cols && cy < world.rows) {
covered.add(cx * 1000 + cy);
}
}
}
}
for (let dy = -R; dy <= R; dy += 1) {
for (let dx = -R; dx <= R; dx += 1) {
const x = unit.x + dx;
const y = unit.y + dy;
if (!inBounds(world, x, y)) continue;
if (!canFoundCity(rules, state, x, y)) continue;
const terr = terrainAt(rules, world, x, y);
if (terr.water || terr.id === 'mountains') continue;
// Don't found right on top of a neighbour's doorstep
const enemyClose = state.civs.some((c) => c.alive && c.id !== civIdx
&& civCities(state, c.id).some((ct) => cheb(ct.x, ct.y, x, y) <= 3));
if (enemyClose) continue;
// Penalize sites too close to own cities (overlapping working radii)
let tooClose = false;
let minOwnDist = Infinity;
for (const city of myCities) {
const d = cheb(city.x, city.y, x, y);
if (d < minOwnDist) minOwnDist = d;
if (d <= 3) { tooClose = true; break; } // hard reject: radii overlap almost fully
}
if (tooClose) continue;
const score = aiCitySiteScore(rules, world, x, y, covered)
- cheb(unit.x, unit.y, x, y) * 1.5
- (minOwnDist <= 5 ? (5 - minOwnDist) * 6 : 0); // soft penalty at 4-5 tiles
if (score > bestScore) { bestScore = score; best = { x, y }; }
}
}
return best;
}
function stepWorker(rules, state, civIdx, unit) {
// Improve tiles inside own city radii: irrigation > mine > road.
if (!cityNeedsMe(rules, state, civIdx, unit.x, unit.y)) {
const target = nearestWorkTile(rules, state, civIdx, unit);
if (target) return moveToward(rules, state, unit, target[0], target[1]);
unit.mp = 0;
return false;
}
for (const imp of ['irrigation', 'mine', 'road', 'railroad', 'farmland']) {
if (canWork(rules, state, unit, imp)) {
// Don't irrigate what a mine serves better and vice versa: terrain fields
// already gate this; simple priority order is enough for the AI.
startWork(rules, state, unit, imp);
return false;
}
}
const target = nearestWorkTile(rules, state, civIdx, unit);
if (target) return moveToward(rules, state, unit, target[0], target[1]);
unit.mp = 0;
return false;
}
function cityNeedsMe(rules, state, civIdx, x, y) {
return civCities(state, civIdx).some((c) => cheb(c.x, c.y, x, y) <= 2);
}
function nearestWorkTile(rules, state, civIdx, unit) {
const { world } = state;
let best = null;
let bestDist = Infinity;
for (const city of civCities(state, civIdx)) {
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const x = city.x + dx;
const y = city.y + dy;
if (!inBounds(world, x, y)) continue;
const terr = terrainAt(rules, world, x, y);
if (terr.water) continue;
const bits = world.improvements[tileIndex(world, x, y)];
const needsSomething = (!(bits & 4) && terr.irrigate !== null)
|| (!(bits & 16) && terr.mine !== null) || !(bits & 1);
if (!needsSomething) continue;
const d = cheb(unit.x, unit.y, x, y);
if (d < bestDist) { bestDist = d; best = [x, y]; }
}
}
}
return best;
}
function stepExplorer(rules, state, civIdx, unit) {
const target = nearestFrontier(state, civIdx, unit);
if (!target) { unit.mp = 0; return false; }
return moveToward(rules, state, unit, target[0], target[1]);
}
function nearestFrontier(state, civIdx, unit) {
const { world } = state;
const grid = state.explored[civIdx];
let best = null;
let bestDist = Infinity;
// Sample the map (stride 2) for unexplored tiles adjacent to explored ones.
for (let y = 0; y < world.rows; y += 2) {
for (let x = 0; x < world.cols; x += 2) {
if (grid[tileIndex(world, x, y)]) continue;
const d = cheb(unit.x, unit.y, x, y);
if (d < bestDist) { bestDist = d; best = [x, y]; }
}
}
return best;
}
function stepCaravan(rules, state, civIdx, unit) {
if (canEstablishRoute(rules, state, unit)) {
establishTradeRoute(rules, state, unit);
return false;
}
const home = cityById(state, unit.homeCity) ?? civCities(state, civIdx)[0];
if (!home) { unit.mp = 0; return false; }
if (!unit.homeCity) unit.homeCity = home.id;
let best = null;
let bestScore = -Infinity;
for (const city of state.cities) {
if (city.id === home.id) continue;
if (city.civ !== civIdx && state.civs[civIdx].relations[city.civ] !== 'peace'
&& state.civs[civIdx].relations[city.civ] !== 'alliance') continue;
const d = cheb(home.x, home.y, city.x, city.y);
if (d < 8) continue;
const score = (city.civ === civIdx ? 0 : 20) + d - cheb(unit.x, unit.y, city.x, city.y) * 0.5;
if (score > bestScore) { bestScore = score; best = city; }
}
if (!best) { unit.mp = 0; return false; }
return moveToward(rules, state, unit, best.x, best.y);
}
function stepShip(rules, state, civIdx, unit, strategy) {
const def = rules.units[unit.type];
// Warships hunt enemy ships/coastal targets during war; else patrol home.
if (def.attack > 0 && strategy.atWarWith.length) {
const target = state.units.find((u) => strategy.atWarWith.includes(u.civ)
&& rules.units[u.type].domain === 'sea' && cheb(u.x, u.y, unit.x, unit.y) <= 6);
if (target) {
if (cheb(target.x, target.y, unit.x, unit.y) <= 1) {
resolveAttack(rules, state, unit, target.x, target.y);
return false;
}
return moveToward(rules, state, unit, target.x, target.y);
}
}
unit.mp = 0;
return false;
}
function handleCargo(rules, state, unit, strategy) {
// Disembark next to a hostile city or onto open land when the boat parks.
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
for (const [dx, dy] of dirs) {
const x = unit.x + dx;
const y = unit.y + dy;
if (!inBounds(state.world, x, y)) continue;
if (terrainAt(rules, state.world, x, y).water) continue;
const city = cityAt(state, x, y);
if (city && strategy.atWarWith.includes(city.civ)) {
disembark(rules, state, unit, dx, dy);
return;
}
}
}
function stepMilitary(rules, state, civIdx, unit, strategy) {
const civ = state.civs[civIdx];
const def = rules.units[unit.type];
const homeCity = cityAt(state, unit.x, unit.y);
// Keep the garrison staffed before adventuring.
if (homeCity && homeCity.civ === civIdx) {
const garrison = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx
&& rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat'));
const wanted = strategy.phase === 'war' ? 2 : 1;
const myRank = garrison.sort((a, b) => rules.units[b.type].defense - rules.units[a.type].defense)
.indexOf(unit);
if (myRank >= 0 && myRank < wanted) {
unit.fortified = true;
unit.mp = 0;
return false;
}
}
// A cornered Barbarian Leader next door is gold on the ground. Checked before
// the attack loop so the AI takes the ransom rather than walking past it —
// otherwise the human would collect every leader in the game.
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
if (def.domain === 'land') {
for (const [dx, dy] of dirs) {
const x = unit.x + dx;
const y = unit.y + dy;
if (!inBounds(state.world, x, y)) continue;
const there = unitsAt(state, x, y);
if (!there.length || cityAt(state, x, y)) continue;
if (!there.every((u) => u.civ !== civIdx && rules.units[u.type].flags.includes('leader'))) continue;
const out = tryMove(rules, state, unit, dx, dy);
if (out.result === 'ransom') return true;
}
}
// Attack adjacent enemies when odds look good.
for (const [dx, dy] of dirs) {
const x = unit.x + dx;
const y = unit.y + dy;
if (!inBounds(state.world, x, y)) continue;
const enemies = unitsAt(state, x, y).filter((u) => civ.relations[u.civ] === 'war');
const enemyCity = cityAt(state, x, y);
const cityHostile = enemyCity && civ.relations[enemyCity.civ] === 'war';
if (!enemies.length && !cityHostile) continue;
if (def.domain === 'land' && terrainAt(rules, state.world, x, y).water) continue;
if (def.attack <= 0) continue;
if (enemies.length) {
const defender = pickDefender(rules, state, x, y, unit);
const A = attackerStrength(rules, state, unit);
const D = defenderStrength(rules, state, defender, unit);
// Massed assault: with friends beside us, grind the walls down even at
// poor odds — defenders keep their damage between waves.
const allies = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx
&& rules.units[u.type].attack >= 2).length;
const gate = cityHostile ? (allies >= 2 ? 0.35 : 0.6) : 0.8;
if (A >= D * gate) {
const out = tryMove(rules, state, unit, dx, dy);
return out.result === 'combat';
}
} else if (cityHostile) {
const out = tryMove(rules, state, unit, dx, dy);
return out.result === 'captured';
}
}
// Hunt a sighted Barbarian Leader. Without this the AI only ever stumbles
// into a ransom by accident — measured at zero ransoms across a 28-game
// soak — so the "kill hordes, draw out a leader" loop paid the human only.
// Range-limited so it's a detour for nearby troops, not an empire-wide
// stampede; the leader itself is move 1, so mounted units can run it down.
if (def.attack > 0 && def.domain === 'land') {
const leader = currentLeader(state);
if (leader && cheb(unit.x, unit.y, leader.x, leader.y) <= LEADER_HUNT_RANGE) {
if (moveToward(rules, state, unit, leader.x, leader.y)) return true;
}
}
// March on the weakest reachable enemy city. Barbarian-held cities are added
// explicitly: they're deliberately absent from atWarWith (which would pin
// the civ in war phase), so without this a city lost to raiders would never
// be retaken by anyone.
const retakeable = barbarianCities(state);
if ((strategy.atWarWith.length || retakeable.length) && def.attack > 0) {
let target = null;
let bestScore = -Infinity;
for (const city of state.cities) {
if (!strategy.atWarWith.includes(city.civ) && !retakeable.includes(city)) continue;
const defenders = unitsAt(state, city.x, city.y).length;
const d = cheb(unit.x, unit.y, city.x, city.y);
const score = -defenders * 4 - d;
if (score > bestScore) { bestScore = score; target = city; }
}
if (target) return moveToward(rules, state, unit, target.x, target.y);
}
// Peacetime: a slice of the army scouts outward (finds neighbours, pops
// huts); the rest drifts home and fortifies.
if (unit.id % 3 === 0 && strategy.atWarWith.length === 0) {
const frontier = nearestFrontier(state, civIdx, unit);
if (frontier && moveToward(rules, state, unit, frontier[0], frontier[1])) return true;
}
const own = civCities(state, civIdx);
if (own.length) {
let nearest = own[0];
for (const c of own) {
if (cheb(unit.x, unit.y, c.x, c.y) < cheb(unit.x, unit.y, nearest.x, nearest.y)) nearest = c;
}
if (cheb(unit.x, unit.y, nearest.x, nearest.y) > 1) {
return moveToward(rules, state, unit, nearest.x, nearest.y);
}
}
unit.fortified = true;
unit.mp = 0;
return false;
}