166 lines
6.1 KiB
JavaScript
166 lines
6.1 KiB
JavaScript
// Supply: encirclement and siege detection, including the maritime exception
|
|
// that keeps a coastal city supplied through allied naval units.
|
|
|
|
import { key } from "../hex.js";
|
|
import { STATUS_ENCIRCLED, STATUS_BESIEGED } from "../data/statuses.js";
|
|
|
|
export const statusMethods = {
|
|
// Recomputes the status of every unit and city. The supply search only runs
|
|
// for an occupant that has a hostile unit adjacent, and only once a day (and
|
|
// at setup), so it never touches the snapshot path.
|
|
_refreshStatuses() {
|
|
const components = new Map();
|
|
for (const unit of this.units) {
|
|
if (!this._hasAdjacentHostile(unit.coords, unit.civ)) {
|
|
unit.statuses = [];
|
|
continue;
|
|
}
|
|
unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ));
|
|
}
|
|
for (const city of this.cities) {
|
|
if (!this._hasAdjacentHostile(city.coords, city.civ)) {
|
|
city.statuses = [];
|
|
continue;
|
|
}
|
|
city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ));
|
|
}
|
|
},
|
|
|
|
// The trigger: encirclement is only evaluated when a hostile unit is next to
|
|
// the tile.
|
|
_hasAdjacentHostile(coords, civ) {
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
_componentsFor(cache, civ) {
|
|
if (!cache.has(civ)) cache.set(civ, this._landComponents(civ));
|
|
return cache.get(civ);
|
|
},
|
|
|
|
_isNaval(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return !!(proto && proto.traversableTerrains.includes("Sea"));
|
|
},
|
|
|
|
// Land connected components for one civ, with enemy-occupied and enemy-held
|
|
// city tiles acting as walls. Each component remembers how many of the civ's
|
|
// cities it contains, which is all the supply check needs.
|
|
_landComponents(civ) {
|
|
const byTile = new Map();
|
|
const cityCountByComp = new Map();
|
|
const totalCities = this.cities.filter((city) => city.civ === civ).length;
|
|
let component = 0;
|
|
for (const start of this.landCells) {
|
|
const startKey = key(start.x, start.y);
|
|
if (byTile.has(startKey) || !this._landPassable(start, civ)) continue;
|
|
const id = component++;
|
|
byTile.set(startKey, id);
|
|
let cities = 0;
|
|
let frontier = [start];
|
|
while (frontier.length > 0) {
|
|
const next = [];
|
|
for (const coords of frontier) {
|
|
const city = this.cityAt(coords);
|
|
if (city && city.civ === civ) cities += 1;
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue;
|
|
byTile.set(nk, id);
|
|
next.push(neighbour);
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
cityCountByComp.set(id, cities);
|
|
}
|
|
return { byTile, cityCountByComp, totalCities };
|
|
},
|
|
|
|
_landPassable(coords, civ) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
if (!tile || tile.terrainClass !== "Land") return false;
|
|
if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false;
|
|
const city = this.cityAt(coords);
|
|
if (city && city.civ !== civ) return false;
|
|
return true;
|
|
},
|
|
|
|
// A city is besieged when it no longer shares a land component with any
|
|
// other city of its civ. A lone city is never besieged.
|
|
_isCityBesieged(city, info) {
|
|
if (info.totalCities < 2) return false;
|
|
const component = info.byTile.get(key(city.coords.x, city.coords.y));
|
|
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
|
|
return count <= 1;
|
|
},
|
|
|
|
_unitStatuses(unit, info) {
|
|
const city = this.cityAt(unit.coords);
|
|
const inOwnCity = city && city.civ === unit.civ;
|
|
let encircled;
|
|
if (inOwnCity) {
|
|
encircled = this._isCityBesieged(city, info);
|
|
} else {
|
|
const component = info.byTile.get(key(unit.coords.x, unit.coords.y));
|
|
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
|
|
encircled = count === 0;
|
|
}
|
|
const excludeCity = inOwnCity ? city.id : 0;
|
|
if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) {
|
|
encircled = false;
|
|
}
|
|
return encircled ? [STATUS_ENCIRCLED] : [];
|
|
},
|
|
|
|
_cityStatuses(city, info) {
|
|
const besieged = this._isCityBesieged(city, info) &&
|
|
!this._hasMaritimeRoute(city.coords, city.civ, city.id);
|
|
return besieged ? [STATUS_BESIEGED] : [];
|
|
},
|
|
|
|
// The maritime exception: with an allied naval unit on an adjacent sea tile,
|
|
// a lane is traced across water to a different coastal allied city. Enemy
|
|
// units on the water block it.
|
|
_hasMaritimeRoute(origin, civ, excludeCityId) {
|
|
const visited = new Set();
|
|
let frontier = [];
|
|
for (const neighbour of this._neighbours(origin)) {
|
|
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
|
if (!tile || tile.terrainClass !== "Sea") continue;
|
|
if (!this.unitsAt(neighbour).some((unit) => unit.civ === civ && this._isNaval(unit))) continue;
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (visited.has(nk)) continue;
|
|
visited.add(nk);
|
|
frontier.push(neighbour);
|
|
}
|
|
while (frontier.length > 0) {
|
|
const next = [];
|
|
for (const coords of frontier) {
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const city = this.cityAt(neighbour);
|
|
if (city && city.civ === civ && city.id !== excludeCityId) return true;
|
|
}
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
|
if (!tile || tile.terrainClass !== "Sea") continue;
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (visited.has(nk)) continue;
|
|
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) continue;
|
|
visited.add(nk);
|
|
next.push(neighbour);
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
_statusHpLossPerDay(def, inCity) {
|
|
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
|
|
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
|
|
},
|
|
};
|