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