Files
Battle-for-Tismo/shared/game_state/statuses.js
T

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