refactor(ai): improve city placement with working radius awareness

- Add aiCitySiteScore to evaluate tiles only outside existing city radii
- Pre-compute covered tiles to avoid overlapping working ranges
- Increase search radius from 7 to 12 for finding distant good sites
- Reject sites within 3 tiles of own cities; soft penalty at 4-5 tiles
- Replace deprecated siteQuality import with new scoring logic
This commit is contained in:
Brian Fertig 2026-07-25 11:27:17 -06:00
parent 929d27e188
commit 65a6ee12ad
1 changed files with 56 additions and 3 deletions

View File

@ -21,7 +21,6 @@ import {
considerRequest, considerGift, makeTreatyRequest, resolveRequest, aiWouldAccept,
escalateFrustration, checkPledges, pickFairTrade, setCooldown,
} from './CivilizationDiplomacy.js';
import { siteQuality } from './CivilizationWorldGen.js';
const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops
@ -437,11 +436,50 @@ function stepSettler(rules, state, civIdx, unit, strategy) {
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 = 7;
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;
@ -450,10 +488,25 @@ export function bestCitySite(rules, state, civIdx, unit) {
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;
const score = siteQuality(rules, world, x, y) - cheb(unit.x, unit.y, x, y) * 2;
// 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 }; }
}
}