1797 lines
59 KiB
JavaScript
1797 lines
59 KiB
JavaScript
// Authoritative game model, ported from scripts/server/game_state.gd. It owns
|
|
// the generated world, cities, units, territory, population, the economy and
|
|
// training queues and is completely free of any rendering or transport
|
|
// concerns, so the Node server can own it and both sides can share the data
|
|
// catalogue. Clients never run this class against a live game; they render
|
|
// snapshots of it and send orders back.
|
|
|
|
import { MapTopology, key } from "./hex.js";
|
|
import { MapGenerator } from "./map_generator.js";
|
|
import { TerrainStats } from "./terrain_stats.js";
|
|
import { HexPathfinder } from "./hex_pathfinder.js";
|
|
import { Random, randomSeed } from "./rng.js";
|
|
import {
|
|
ECONOMY,
|
|
MAP_CONFIG,
|
|
PROTO_UNITS,
|
|
BUILDINGS,
|
|
GOVERNMENTS,
|
|
TECHNOLOGIES,
|
|
EFFECT_GDP,
|
|
EFFECT_BUDGET_INCOME,
|
|
EFFECT_RESEARCH,
|
|
EFFECT_CULTURE,
|
|
STATUS_ENCIRCLED,
|
|
STATUS_BESIEGED,
|
|
STATUS_BATTLE,
|
|
RELATION_PEACE,
|
|
RELATION_WAR,
|
|
NEWS_WAR,
|
|
NEWS_CAPTURE,
|
|
statusById,
|
|
} from "./data.js";
|
|
import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet, defenseMultiplier } from "./rules.js";
|
|
import { dateString } from "./game_clock.js";
|
|
|
|
// The news feed keeps the most recent entries and drops the oldest ones, so a
|
|
// long-running game does not grow the snapshot without bound.
|
|
export const MAX_NEWS = 100;
|
|
|
|
export const UNIT_VISION = 2;
|
|
export const TERRITORY_VISION = 1;
|
|
export const HOURS_PER_DAY = 24;
|
|
export const DAYS_PER_YEAR = 365;
|
|
export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR;
|
|
export const CONTROLLED_SPEED_MULTIPLIER = 1.5;
|
|
export const DEFAULT_UNIT_SPEED = 0.5;
|
|
export const MIN_STEP_COST = 0.0001;
|
|
// A defending unit's defense is doubled: a 100% defensive bonus.
|
|
export const DEFENDER_DEFENSE_MULTIPLIER = 2.0;
|
|
|
|
export class GameState {
|
|
constructor() {
|
|
this.mapConfig = MAP_CONFIG;
|
|
this.civilisations = [];
|
|
this.protoUnits = PROTO_UNITS.slice();
|
|
this.protoBuildings = BUILDINGS.slice();
|
|
this.governments = GOVERNMENTS.slice();
|
|
this.technologies = TECHNOLOGIES.slice();
|
|
|
|
this.seed = 0;
|
|
this.topology = null;
|
|
this.tiles = {};
|
|
this.terrainStats = null;
|
|
this.landCells = [];
|
|
|
|
this.cities = [];
|
|
this.units = [];
|
|
this.territory = new Map();
|
|
this.tilePopulation = new Map();
|
|
this.budgets = new Map();
|
|
this.training = new Map();
|
|
this.research = new Map();
|
|
this.culture = new Map();
|
|
this.government = new Map();
|
|
this.researched = new Map();
|
|
this.explored = new Map();
|
|
this.visible = new Map();
|
|
// Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
|
|
// at war. Every other pair is at peace by default.
|
|
this.wars = new Set();
|
|
this.news = [];
|
|
|
|
this.totalHours = 0;
|
|
|
|
this._configured = false;
|
|
this._generator = null;
|
|
this._pathfinder = new HexPathfinder();
|
|
this._minMovementCost = 1.0;
|
|
this._maxStepLength = 1.0;
|
|
this._nextUnitId = 1;
|
|
this._nextCityId = 1;
|
|
this._nextNewsId = 1;
|
|
this._cityNamesUsed = new Map();
|
|
this._unitById = new Map();
|
|
this._cityById = new Map();
|
|
this._cityByCoords = new Map();
|
|
this._cityAdjacent = new Map();
|
|
this._territoryByCiv = new Map();
|
|
this._gdpPerCapitaCache = new Map();
|
|
this._serializedTerritory = [];
|
|
this._serializedExplored = new Map();
|
|
this._neighbourCache = new Map();
|
|
this._visibilityDirty = true;
|
|
|
|
this._listeners = [];
|
|
}
|
|
|
|
onChanged(fn) {
|
|
this._listeners.push(fn);
|
|
}
|
|
|
|
_emitChanged() {
|
|
for (const fn of this._listeners) fn();
|
|
}
|
|
|
|
isConfigured() {
|
|
return this._configured;
|
|
}
|
|
|
|
configure(civs, worldSeed = 0) {
|
|
this.civilisations = civs.slice();
|
|
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
|
|
this.topology = new MapTopology(
|
|
this.mapConfig.mapSize,
|
|
this.mapConfig.topology === "cylindrical"
|
|
);
|
|
this._generator = new MapGenerator(this.mapConfig, this.topology);
|
|
this._generator.generate(this.seed);
|
|
this.tiles = this._generator.tiles;
|
|
this.landCells = this._generator.landCells;
|
|
this.terrainStats = new TerrainStats(this.tiles);
|
|
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
|
|
this._maxStepLength = this._computeMaxStepLength();
|
|
|
|
this.cities = [];
|
|
this.units = [];
|
|
this.territory = new Map();
|
|
this.tilePopulation = new Map();
|
|
this.budgets = new Map();
|
|
this.training = new Map();
|
|
this.research = new Map();
|
|
this.culture = new Map();
|
|
this.government = new Map();
|
|
this.researched = new Map();
|
|
this.wars = new Set();
|
|
this.news = [];
|
|
this._nextNewsId = 1;
|
|
this._nextUnitId = 1;
|
|
this._nextCityId = 1;
|
|
this._unitById = new Map();
|
|
this._cityById = new Map();
|
|
this._cityNamesUsed = new Map();
|
|
this._cityByCoords = new Map();
|
|
this._cityAdjacent = new Map();
|
|
this._territoryByCiv = new Map();
|
|
this._gdpPerCapitaCache = new Map();
|
|
this._serializedTerritory = [];
|
|
this._serializedExplored = new Map();
|
|
this._neighbourCache = new Map();
|
|
this.explored = new Map();
|
|
this.visible = new Map();
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
this.research.set(i, 0);
|
|
this.culture.set(i, 0);
|
|
this.government.set(i, 0);
|
|
this.researched.set(i, new Set());
|
|
}
|
|
this.totalHours = 0;
|
|
this._configured = true;
|
|
|
|
this._placeSettlements();
|
|
this._refreshStatuses();
|
|
this._revealAll();
|
|
this._refreshVisibility();
|
|
this._visibilityDirty = false;
|
|
this._emitChanged();
|
|
}
|
|
|
|
requestTrain(cityId, protoIndex) {
|
|
const city = this.findCity(cityId);
|
|
if (!city || this.training.has(cityId)) return false;
|
|
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
|
|
const proto = this.protoUnits[protoIndex];
|
|
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
|
if (capacity <= 0 || proto.cost <= 0) {
|
|
this._spawnUnit(city.coords, city.civ, proto);
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
this.training.set(cityId, {
|
|
protoIndex,
|
|
capacity,
|
|
elapsedHours: 0,
|
|
totalHours: proto.cost / capacity,
|
|
});
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
requestBuild(cityId, protoIndex) {
|
|
const city = this.findCity(cityId);
|
|
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
|
const proto = this.protoBuildings[protoIndex];
|
|
const civ = city.civ;
|
|
const level = this.getCityBuildingLevel(city, protoIndex);
|
|
if (level >= proto.maxLevel) return false;
|
|
const gdp = this.getPlayerGdp(civ);
|
|
const cost = buildingBuildCost(proto, gdp, level);
|
|
if (cost > 0 && this.getBudget(civ) < cost) return false;
|
|
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
|
|
city.buildings[protoIndex] = level + 1;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
requestDemolish(cityId, protoIndex) {
|
|
const city = this.findCity(cityId);
|
|
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
|
const proto = this.protoBuildings[protoIndex];
|
|
const civ = city.civ;
|
|
const level = this.getCityBuildingLevel(city, protoIndex);
|
|
if (level <= 0) return false;
|
|
const refund = buildingRefund(proto, this.getPlayerGdp(civ), level);
|
|
if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund);
|
|
if (level <= 1) delete city.buildings[protoIndex];
|
|
else city.buildings[protoIndex] = level - 1;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
requestSetGovernment(civ, index) {
|
|
if (civ < 0 || civ >= this.civilisations.length) return false;
|
|
if (index < 0 || index >= this.governments.length) return false;
|
|
if (this.getGovernment(civ) === index) return false;
|
|
const cost = GOVERNMENTS[index].baseCost +
|
|
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
|
|
if (cost > 0 && this.getBudget(civ) < cost) return false;
|
|
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
|
|
this.government.set(civ, index);
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
requestResearchTechnology(civ, index) {
|
|
if (!this.canResearchTechnology(civ, index)) return false;
|
|
const technology = this.technologies[index];
|
|
if (this.getResearch(civ) < technology.cost) return false;
|
|
this.research.set(civ, this.getResearch(civ) - technology.cost);
|
|
this.researched.get(civ).add(index);
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------- diplomacy --
|
|
|
|
isAtWar(a, b) {
|
|
if (a < 0 || b < 0 || a === b) return false;
|
|
return this.wars.has(warKey(a, b));
|
|
}
|
|
|
|
getRelation(a, b) {
|
|
return this.isAtWar(a, b) ? RELATION_WAR : RELATION_PEACE;
|
|
}
|
|
|
|
// Declares war from `civ` on `target`. Fails for unknown or identical
|
|
// civilisations and when the two are already at war. The declaration is
|
|
// newsworthy and reaches every player through the next snapshot.
|
|
requestDeclareWar(civ, target) {
|
|
if (!this._validCiv(civ) || !this._validCiv(target) || civ === target) return false;
|
|
if (!this._declareWar(civ, target)) return false;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
_validCiv(civ) {
|
|
return Number.isInteger(civ) && civ >= 0 && civ < this.civilisations.length;
|
|
}
|
|
|
|
// Records the war and its news item without emitting; the caller decides when
|
|
// to publish. Returns false when the pair is already at war.
|
|
_declareWar(actor, target) {
|
|
if (actor === target) return false;
|
|
const k = warKey(actor, target);
|
|
if (this.wars.has(k)) return false;
|
|
this.wars.add(k);
|
|
this._addNews({ type: NEWS_WAR, actor, target });
|
|
return true;
|
|
}
|
|
|
|
getNews() {
|
|
return this.news;
|
|
}
|
|
|
|
_addNews(entry) {
|
|
const record = {
|
|
id: this._nextNewsId,
|
|
totalHours: this.totalHours,
|
|
date: dateString(this.totalHours),
|
|
...entry,
|
|
};
|
|
this._nextNewsId += 1;
|
|
this.news.push(record);
|
|
while (this.news.length > MAX_NEWS) this.news.shift();
|
|
return record;
|
|
}
|
|
|
|
requestMove(unitId, goal) {
|
|
const unit = this.findUnit(unitId);
|
|
if (!unit) return false;
|
|
const proto = this.unitProto(unit);
|
|
if (!proto || !proto.moveable) return false;
|
|
// Ordering a move onto an enemy military unit is an attack: remember the
|
|
// target so the unit stops as soon as it is within range instead of walking
|
|
// all the way into it.
|
|
const target = this.unitAt(goal);
|
|
const attacking = !!target && target !== unit && target.civ !== unit.civ &&
|
|
this._isMilitary(unit) && this._isMilitary(target);
|
|
unit.attackTarget = attacking ? target.id : null;
|
|
const path = this.findPath(unit, unit.coords, goal);
|
|
if (path.length < 2) {
|
|
unit.attackTarget = null;
|
|
return false;
|
|
}
|
|
// Marching a military unit into, or attacking, a country we are at peace
|
|
// with declares war on it. The browser confirms the move first, but the
|
|
// server enforces the rule so the two can never disagree.
|
|
if (this._isMilitary(unit)) {
|
|
if (attacking) this._declareWar(unit.civ, target.civ);
|
|
for (const coords of path) {
|
|
const owner = this.civAt(coords);
|
|
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
|
|
}
|
|
}
|
|
unit.path = path;
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
advanceHour() {
|
|
this.advanceMovement(1.0);
|
|
this.tickHour();
|
|
}
|
|
|
|
advanceMovement(hours) {
|
|
if (hours <= 0) return;
|
|
// Units in contact are locked in place until the daily combat round
|
|
// resolves, so neither side can slip away mid-fight.
|
|
const engaged = this._engagedUnits();
|
|
for (const unit of this.units) {
|
|
if (!unit.path || unit.path.length === 0) continue;
|
|
if (engaged.has(unit)) continue;
|
|
let hoursLeft = hours;
|
|
while (hoursLeft > 0 && unit.pathIndex < unit.path.length - 1) {
|
|
// Stop as soon as the target is in range: a ranged unit holds its
|
|
// distance rather than closing to melee.
|
|
if (this._combatTarget(unit)) break;
|
|
const next = unit.path[unit.pathIndex + 1];
|
|
const occupant = this.unitAt(next);
|
|
if (occupant && occupant !== unit) {
|
|
// A hostile military unit in the way is the fight we are marching
|
|
// into: hold position until it dies rather than treating the tile as
|
|
// an obstacle. Anyone else is an obstacle; route around it, and when
|
|
// the destination has become unreachable, abandon the move instead of
|
|
// stalling on the spot forever.
|
|
if (this._isBlockingEnemy(unit, occupant)) break;
|
|
if (this._repathAround(unit)) {
|
|
const ahead = this.unitAt(unit.path[unit.pathIndex + 1]);
|
|
if (ahead && ahead !== unit) break;
|
|
continue;
|
|
}
|
|
this._stopUnit(unit);
|
|
break;
|
|
}
|
|
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
|
|
const needed = cost - unit.progressHours;
|
|
if (hoursLeft >= needed) {
|
|
hoursLeft -= needed;
|
|
unit.pathIndex += 1;
|
|
unit.progressHours = 0;
|
|
unit.coords = next;
|
|
this._visibilityDirty = true;
|
|
this._onUnitArrived(unit);
|
|
} else {
|
|
unit.progressHours += hoursLeft;
|
|
hoursLeft = 0;
|
|
}
|
|
}
|
|
if (unit.path && unit.pathIndex >= unit.path.length - 1) {
|
|
this._stopUnit(unit);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A hostile military unit standing on the next tile is an opponent to fight,
|
|
// not an obstacle to route around.
|
|
_isBlockingEnemy(unit, occupant) {
|
|
return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ;
|
|
}
|
|
|
|
// Recomputes a route from where the unit stands to the destination it was
|
|
// already heading for. Returns false when no route exists any more.
|
|
_repathAround(unit) {
|
|
if (!unit.path || unit.path.length < 2) return false;
|
|
const goal = unit.path[unit.path.length - 1];
|
|
const path = this.findPath(unit, unit.coords, goal);
|
|
if (path.length < 2) return false;
|
|
unit.path = path;
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
return true;
|
|
}
|
|
|
|
// Abandons every movement for the unit: no route, no attack, no partial step.
|
|
_stopUnit(unit) {
|
|
unit.path = [];
|
|
unit.pathIndex = 0;
|
|
unit.progressHours = 0;
|
|
unit.attackTarget = null;
|
|
}
|
|
|
|
_engagedUnits() {
|
|
const engaged = new Set();
|
|
for (const { attacker, defender } of this._combatPairs()) {
|
|
engaged.add(attacker);
|
|
// A defender that cannot strike back (an out-ranged melee unit) is free
|
|
// to keep moving; one that is in reach is locked in the exchange.
|
|
if (this._canReach(defender, attacker)) engaged.add(defender);
|
|
}
|
|
return engaged;
|
|
}
|
|
|
|
_canReach(unit, target) {
|
|
return this.topology.tileDistance(unit.coords, target.coords) <= this._unitRange(unit);
|
|
}
|
|
|
|
// Unique attacker/defender pairs, so two units that ordered moves into each
|
|
// other fight one round rather than two. The lower id is the attacker.
|
|
_combatPairs() {
|
|
const pairs = new Map();
|
|
for (const unit of this.units) {
|
|
const defender = this._combatTarget(unit);
|
|
if (!defender) continue;
|
|
const pairKey = unit.id < defender.id
|
|
? `${unit.id}:${defender.id}`
|
|
: `${defender.id}:${unit.id}`;
|
|
if (!pairs.has(pairKey)) pairs.set(pairKey, { attacker: unit, defender });
|
|
}
|
|
return Array.from(pairs.values());
|
|
}
|
|
|
|
// The enemy military unit the given unit is attacking, or null. A unit
|
|
// attacks the target it was ordered onto, falling back to whatever occupies
|
|
// the next step of its path; either way the target must be within range.
|
|
_combatTarget(unit) {
|
|
if (!this._isMilitary(unit)) return null;
|
|
let target = unit.attackTarget ? this.findUnit(unit.attackTarget) : null;
|
|
if (!target) {
|
|
const next = this._nextStepTile(unit);
|
|
if (!next) return null;
|
|
target = this.unitAt(next);
|
|
}
|
|
if (!target || target === unit) return null;
|
|
if (target.civ === unit.civ || !this._isMilitary(target)) return null;
|
|
if (this.topology.tileDistance(unit.coords, target.coords) > this._unitRange(unit)) return null;
|
|
return target;
|
|
}
|
|
|
|
_unitRange(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return proto && typeof proto.range === "number" ? proto.range : 1;
|
|
}
|
|
|
|
// 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)) {
|
|
const occupant = this.unitAt(neighbour);
|
|
if (occupant && occupant.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;
|
|
const occupant = this.unitAt(coords);
|
|
if (occupant && occupant.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;
|
|
const occupant = this.unitAt(neighbour);
|
|
if (!occupant || occupant.civ !== civ || !this._isNaval(occupant)) 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;
|
|
const occupant = this.unitAt(neighbour);
|
|
if (occupant && occupant.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;
|
|
}
|
|
|
|
// The ids of every unit currently trading blows, on either side of the
|
|
// exchange. Out-ranged defenders cannot strike back but are still in battle.
|
|
_battleUnitIds() {
|
|
const ids = new Set();
|
|
for (const { attacker, defender } of this._combatPairs()) {
|
|
ids.add(attacker.id);
|
|
ids.add(defender.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// The statuses shown for a unit this instant: the supply statuses stored on
|
|
// the unit plus "in battle", which is derived from the live combat state so
|
|
// it appears and clears with the fighting rather than only on the daily tick.
|
|
_effectiveStatuses(unit, inBattle) {
|
|
const statuses = unit.statuses || [];
|
|
if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses;
|
|
return [...statuses, STATUS_BATTLE];
|
|
}
|
|
|
|
// The combined upkeep multiplier of the given statuses.
|
|
_statusUpkeepMultiplier(statuses) {
|
|
let multiplier = 1;
|
|
for (const id of statuses) {
|
|
const def = statusById(id);
|
|
if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier;
|
|
}
|
|
return multiplier;
|
|
}
|
|
|
|
// Applies every active status once per day. The only effect so far is
|
|
// attrition, but the loop is generic so new daily fields slot in here.
|
|
_applyDailyStatusEffects() {
|
|
const battle = this._battleUnitIds();
|
|
const damage = new Map();
|
|
for (const unit of this.units) {
|
|
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
|
if (statuses.length === 0) continue;
|
|
const inCity = !!this.cityAt(unit.coords);
|
|
let fraction = 0;
|
|
for (const id of statuses) {
|
|
const def = statusById(id);
|
|
if (def) fraction += this._statusHpLossPerDay(def, inCity);
|
|
}
|
|
if (fraction > 0) damage.set(unit, unit.maxHp * fraction);
|
|
}
|
|
if (damage.size === 0) return;
|
|
const destroyed = [];
|
|
for (const [unit, amount] of damage) {
|
|
unit.hp -= amount;
|
|
if (unit.hp <= 0) {
|
|
unit.hp = 0;
|
|
destroyed.push(unit);
|
|
}
|
|
}
|
|
for (const unit of destroyed) this._destroyUnit(unit);
|
|
if (destroyed.length > 0) this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
}
|
|
|
|
_nextStepTile(unit) {
|
|
const path = unit.path;
|
|
if (!path || path.length < 2) return null;
|
|
if (unit.pathIndex + 1 >= path.length) return null;
|
|
return path[unit.pathIndex + 1];
|
|
}
|
|
|
|
// One combat round: at most one per day, resolved in `tickHour`. Both sides
|
|
// trade blows and damage lands simultaneously, so a mutual kill destroys both
|
|
// units.
|
|
_resolveCombatRound() {
|
|
const damage = new Map();
|
|
for (const { attacker, defender } of this._combatPairs()) {
|
|
const distance = this.topology.tileDistance(attacker.coords, defender.coords);
|
|
const toDefender = Math.max(
|
|
1,
|
|
this._unitAttack(attacker) - this._effectiveDefense(defender, true)
|
|
);
|
|
damage.set(defender, (damage.get(defender) || 0) + toDefender);
|
|
// Only return fire when the attacker is inside the defender's own range,
|
|
// so an out-ranged melee unit cannot reach an artillery piece.
|
|
if (distance <= this._unitRange(defender)) {
|
|
const toAttacker = Math.max(
|
|
1,
|
|
this._unitAttack(defender) - this._effectiveDefense(attacker, false)
|
|
);
|
|
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
|
|
}
|
|
}
|
|
if (damage.size === 0) return;
|
|
const destroyed = [];
|
|
for (const [unit, amount] of damage) {
|
|
unit.hp -= amount;
|
|
if (unit.hp <= 0) {
|
|
unit.hp = 0;
|
|
destroyed.push(unit);
|
|
}
|
|
}
|
|
for (const unit of destroyed) this._destroyUnit(unit);
|
|
if (destroyed.length > 0) {
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
}
|
|
}
|
|
|
|
_destroyUnit(unit) {
|
|
const index = this.units.indexOf(unit);
|
|
if (index >= 0) this.units.splice(index, 1);
|
|
this._unitById.delete(unit.id);
|
|
}
|
|
|
|
// Called when a unit finishes stepping onto a tile. A military unit entering
|
|
// an enemy city captures it.
|
|
_onUnitArrived(unit) {
|
|
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
|
|
if (city && city.civ !== unit.civ && this._isMilitary(unit)) {
|
|
this._captureCity(city, unit.civ);
|
|
}
|
|
}
|
|
|
|
tickHour() {
|
|
this.totalHours += 1;
|
|
this._tickTraining();
|
|
if (this._visibilityDirty) {
|
|
this._refreshVisibility();
|
|
this._visibilityDirty = false;
|
|
}
|
|
if (this.totalHours % HOURS_PER_DAY === 0) {
|
|
this._tickPopulation();
|
|
this._tickEconomy();
|
|
this._refreshStatuses();
|
|
this._applyDailyStatusEffects();
|
|
this._resolveCombatRound();
|
|
}
|
|
this._tickBuildings();
|
|
this._emitChanged();
|
|
}
|
|
|
|
findUnit(unitId) {
|
|
return this._unitById.get(unitId) || null;
|
|
}
|
|
|
|
findCity(cityId) {
|
|
return this._cityById.get(cityId) || null;
|
|
}
|
|
|
|
unitProto(unit) {
|
|
const index = unit.proto;
|
|
if (index < 0 || index >= this.protoUnits.length) return null;
|
|
return this.protoUnits[index];
|
|
}
|
|
|
|
cityAt(coords) {
|
|
return this._cityByCoords.get(key(coords.x, coords.y)) || null;
|
|
}
|
|
|
|
unitAt(coords) {
|
|
for (const unit of this.units) {
|
|
if (unit.coords.x === coords.x && unit.coords.y === coords.y) return unit;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
civAt(coords) {
|
|
return this.territory.has(key(coords.x, coords.y)) ? this.territory.get(key(coords.x, coords.y)) : -1;
|
|
}
|
|
|
|
terrainTypeAt(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
return tile ? tile.terrainType : "Unknown";
|
|
}
|
|
|
|
getTilePopulation(coords) {
|
|
return Math.round(this.tilePopulation.get(key(coords.x, coords.y)) || 0);
|
|
}
|
|
|
|
getTileInfo(coords) {
|
|
const city = this.cityAt(coords);
|
|
return {
|
|
coords,
|
|
owner: this.civAt(coords),
|
|
terrainType: this.terrainTypeAt(coords),
|
|
population: this.getTilePopulation(coords),
|
|
improvements: city ? city.improvements : [],
|
|
};
|
|
}
|
|
|
|
getPlayerPopulation(civ) {
|
|
return Math.round(this._playerAggregates(civ).population);
|
|
}
|
|
|
|
_playerAggregates(civ) {
|
|
let population = 0;
|
|
let baseGdp = 0;
|
|
const cells = this._territoryByCiv.get(civ) || [];
|
|
for (const coords of cells) {
|
|
const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0;
|
|
population += pop;
|
|
baseGdp += pop * this.getTileGdpPerCapita(coords);
|
|
}
|
|
const modifiers = this.getCivModifiers(civ);
|
|
const gdp = baseGdp * (1 + (modifiers[EFFECT_GDP] || 0));
|
|
return { population, gdp };
|
|
}
|
|
|
|
getTileGdpPerCapita(coords) {
|
|
const k = key(coords.x, coords.y);
|
|
if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k);
|
|
const owner = this.civAt(coords);
|
|
if (owner < 0) return 0;
|
|
let perCapita = ECONOMY.baseGdpPerCapita * this.terrainStats.gdpMultiplier(coords);
|
|
const city = this.cityAt(coords);
|
|
if (city) {
|
|
perCapita *= city.isCapital
|
|
? ECONOMY.capitalTileGdpMultiplier
|
|
: ECONOMY.cityTileGdpMultiplier;
|
|
} else if (this._isNextToCity(coords, owner)) {
|
|
perCapita *= 1 + ECONOMY.citySurroundingGdpBonus;
|
|
}
|
|
this._gdpPerCapitaCache.set(k, perCapita);
|
|
return perCapita;
|
|
}
|
|
|
|
getTileGdp(coords) {
|
|
return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords);
|
|
}
|
|
|
|
getPlayerGdp(civ) {
|
|
return this._playerAggregates(civ).gdp;
|
|
}
|
|
|
|
getPlayerUpkeep(civ, gdp = -1) {
|
|
let total = this.getBuildingUpkeep(civ, gdp);
|
|
const battle = this._battleUnitIds();
|
|
for (const unit of this.units) {
|
|
if (unit.civ !== civ) continue;
|
|
const proto = this.unitProto(unit);
|
|
if (!proto) continue;
|
|
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
|
total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
getBuildingUpkeep(civ, gdp = -1) {
|
|
let total = 0;
|
|
if (gdp < 0) gdp = this.getPlayerGdp(civ);
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
for (const indexStr of Object.keys(city.buildings)) {
|
|
const index = Number(indexStr);
|
|
total += buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
getCityBuildingLevel(city, protoIndex) {
|
|
return city.buildings[protoIndex] || 0;
|
|
}
|
|
|
|
// Income and upkeep with the source behind every figure, so the UI can show
|
|
// which buildings, government or technologies shape the budget. Amounts are
|
|
// per in-game hour, matching the rest of the economy figures.
|
|
budgetBreakdown(civ) {
|
|
const gdp = this.getPlayerGdp(civ);
|
|
const rate = ECONOMY.budgetGdpRate;
|
|
const base = gdp * rate;
|
|
const incomeSources = this._incomeSources(civ);
|
|
const modifier = incomeSources.reduce((sum, source) => sum + source.effect, 0);
|
|
const income = base * (1 + modifier);
|
|
const buildingSources = this._buildingUpkeepSources(civ, gdp);
|
|
const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.amount, 0);
|
|
const unitSources = this._unitUpkeepSources(civ);
|
|
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
|
|
return {
|
|
income: {
|
|
rate,
|
|
base,
|
|
modifier,
|
|
total: income,
|
|
sources: incomeSources.map((source) => ({ ...source, amount: base * source.effect })),
|
|
},
|
|
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources },
|
|
unitUpkeep: { total: unitUpkeep, sources: unitSources },
|
|
net: income - buildingUpkeep - unitUpkeep,
|
|
};
|
|
}
|
|
|
|
// Every building, government and researched technology that shifts budget
|
|
// income, combined per source and expressed as a fraction of the base take.
|
|
_incomeSources(civ) {
|
|
const sources = new Map();
|
|
const add = (label, kind, effect) => {
|
|
if (effect === 0) return;
|
|
const k = `${kind}:${label}`;
|
|
const entry = sources.get(k) || { label, kind, effect: 0 };
|
|
entry.effect += effect;
|
|
sources.set(k, entry);
|
|
};
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
for (const indexStr of Object.keys(city.buildings)) {
|
|
const proto = this.protoBuildings[Number(indexStr)];
|
|
const level = city.buildings[indexStr];
|
|
for (const e of proto.effects) {
|
|
if (e.stat === EFFECT_BUDGET_INCOME) add(proto.name, "building", effectAt(e, level));
|
|
}
|
|
}
|
|
}
|
|
const government = this.governments[this.getGovernment(civ)];
|
|
if (government) {
|
|
for (const e of government.effects) {
|
|
if (e.stat === EFFECT_BUDGET_INCOME) add(government.name, "government", effectAt(e, 1));
|
|
}
|
|
}
|
|
const researched = this.researched.get(civ) || new Set();
|
|
for (const index of researched) {
|
|
const tech = this.technologies[index];
|
|
for (const e of tech.effects) {
|
|
if (e.stat === EFFECT_BUDGET_INCOME) add(tech.name, "technology", effectAt(e, 1));
|
|
}
|
|
}
|
|
return this._sortSources(Array.from(sources.values()));
|
|
}
|
|
|
|
_buildingUpkeepSources(civ, gdp) {
|
|
const sources = new Map();
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
for (const indexStr of Object.keys(city.buildings)) {
|
|
const index = Number(indexStr);
|
|
const amount = buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
|
|
if (amount === 0) continue;
|
|
const entry = sources.get(index) || { label: this.protoBuildings[index].name, kind: "building", amount: 0 };
|
|
entry.amount += amount;
|
|
sources.set(index, entry);
|
|
}
|
|
}
|
|
return this._sortSources(Array.from(sources.values()));
|
|
}
|
|
|
|
_unitUpkeepSources(civ) {
|
|
const sources = new Map();
|
|
const battle = this._battleUnitIds();
|
|
for (const unit of this.units) {
|
|
if (unit.civ !== civ) continue;
|
|
const proto = this.unitProto(unit);
|
|
if (!proto || !proto.upkeep) continue;
|
|
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
|
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
|
|
entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
|
|
entry.count += 1;
|
|
sources.set(unit.proto, entry);
|
|
}
|
|
return this._sortSources(Array.from(sources.values()));
|
|
}
|
|
|
|
_sortSources(sources) {
|
|
const weight = (source) => (source.amount !== undefined ? source.amount : source.effect);
|
|
return sources.sort((a, b) => weight(b) - weight(a));
|
|
}
|
|
|
|
getCivBuildingModifiers(civ) {
|
|
const totals = {};
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
for (const indexStr of Object.keys(city.buildings)) {
|
|
const proto = this.protoBuildings[Number(indexStr)];
|
|
const level = city.buildings[indexStr];
|
|
for (const e of proto.effects) {
|
|
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level);
|
|
}
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
getCivGovernmentModifiers(civ) {
|
|
const totals = {};
|
|
const resource = this.governments[this.getGovernment(civ)];
|
|
if (!resource) return totals;
|
|
for (const e of resource.effects) {
|
|
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
getCivModifiers(civ) {
|
|
const totals = this.getCivBuildingModifiers(civ);
|
|
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
|
|
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
|
|
return totals;
|
|
}
|
|
|
|
getResearch(civ) {
|
|
return this.research.get(civ) || 0;
|
|
}
|
|
|
|
getCulture(civ) {
|
|
return this.culture.get(civ) || 0;
|
|
}
|
|
|
|
getGovernment(civ) {
|
|
return this.government.get(civ) || 0;
|
|
}
|
|
|
|
getGovernmentResource(civ) {
|
|
const index = this.getGovernment(civ);
|
|
if (index < 0 || index >= this.governments.length) return null;
|
|
return this.governments[index];
|
|
}
|
|
|
|
isResearched(civ, index) {
|
|
const set = this.researched.get(civ);
|
|
return set ? set.has(index) : false;
|
|
}
|
|
|
|
getResearchedIndices(civ) {
|
|
const set = this.researched.get(civ) || new Set();
|
|
return Array.from(set);
|
|
}
|
|
|
|
canResearchTechnology(civ, index) {
|
|
if (civ < 0 || civ >= this.civilisations.length) return false;
|
|
if (index < 0 || index >= this.technologies.length) return false;
|
|
if (this.isResearched(civ, index)) return false;
|
|
return prerequisitesMet(
|
|
this.technologies[index],
|
|
this.researched.get(civ) || new Set(),
|
|
this.technologies
|
|
);
|
|
}
|
|
|
|
getCivTechnologyModifiers(civ) {
|
|
const totals = {};
|
|
const set = this.researched.get(civ) || new Set();
|
|
for (const index of set) {
|
|
for (const e of this.technologies[index].effects) {
|
|
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
getBudget(civ) {
|
|
return this.budgets.has(civ) ? this.budgets.get(civ) : ECONOMY.startingBudget;
|
|
}
|
|
|
|
getTraining(cityId) {
|
|
return this.training.get(cityId) || null;
|
|
}
|
|
|
|
isExplored(civ, coords) {
|
|
const set = this.explored.get(civ);
|
|
return set ? set.has(key(coords.x, coords.y)) : false;
|
|
}
|
|
|
|
isVisible(civ, coords) {
|
|
const set = this.visible.get(civ);
|
|
return set ? set.has(key(coords.x, coords.y)) : false;
|
|
}
|
|
|
|
canEnter(civ, proto, coords) {
|
|
if (!proto || !this.isExplored(civ, coords)) return false;
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
if (!tile) return false;
|
|
return proto.traversableTerrains.includes(tile.terrainClass);
|
|
}
|
|
|
|
// Whether `unit` may occupy `coords`: passable terrain, no friendly unit in
|
|
// the way and no enemy unit there unless both are military (which turns the
|
|
// step into a fight). Enemy cities are only open to military units, since
|
|
// walking one in captures it.
|
|
//
|
|
// A hostile unit or city is only a valid destination (`isGoal`), never a tile
|
|
// to pass through: pathfinding routes around enemies instead of through them.
|
|
_canUnitEnter(unit, coords, isGoal = false) {
|
|
const proto = this.unitProto(unit);
|
|
if (!this.canEnter(unit.civ, proto, coords)) return false;
|
|
const occupant = this.unitAt(coords);
|
|
if (occupant && occupant !== unit) {
|
|
if (occupant.civ === unit.civ) return false;
|
|
if (!isGoal) return false;
|
|
if (!this._isMilitary(unit) || !this._isMilitary(occupant)) return false;
|
|
}
|
|
const city = this._cityByCoords.get(key(coords.x, coords.y));
|
|
if (city && city.civ !== unit.civ) {
|
|
if (!isGoal) return false;
|
|
if (!this._isMilitary(unit)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
isMilitary(unit) {
|
|
return this._isMilitary(unit);
|
|
}
|
|
|
|
_isMilitary(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return !!(proto && proto.military);
|
|
}
|
|
|
|
_unitAttack(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return proto && typeof proto.attack === "number" ? proto.attack : 0;
|
|
}
|
|
|
|
_unitDefense(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return proto && typeof proto.defense === "number" ? proto.defense : 0;
|
|
}
|
|
|
|
// Fraction of extra defense granted by the tile a unit occupies (terrain
|
|
// plus any city), as a multiplier.
|
|
_defenseMultiplierAt(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
return defenseMultiplier(tile, !!this.cityAt(coords));
|
|
}
|
|
|
|
// A unit's base defense, boosted by its tile, and doubled when it is the
|
|
// defending side of the exchange.
|
|
_effectiveDefense(unit, isDefender) {
|
|
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
|
|
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
|
|
return defense;
|
|
}
|
|
|
|
findPath(unit, start, goal) {
|
|
const proto = this.unitProto(unit);
|
|
const civ = unit.civ;
|
|
const enterCache = new Map();
|
|
const costCache = new Map();
|
|
const heuristicCache = new Map();
|
|
return this._pathfinder.findPath(
|
|
start,
|
|
goal,
|
|
(coords) => this._neighbours(coords),
|
|
(coords, isGoal) => {
|
|
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
|
|
if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal));
|
|
return enterCache.get(k);
|
|
},
|
|
(_from, to) => {
|
|
const k = key(to.x, to.y);
|
|
if (!costCache.has(k)) costCache.set(k, this._tileTravelHours(unit, to));
|
|
return costCache.get(k);
|
|
},
|
|
(from, to) => {
|
|
const k = key(from.x, from.y);
|
|
if (!heuristicCache.has(k)) heuristicCache.set(k, this._heuristic(unit, from, to));
|
|
return heuristicCache.get(k);
|
|
}
|
|
);
|
|
}
|
|
|
|
_placeSettlements() {
|
|
const capitals = this._placeCapitals(this.civilisations.length);
|
|
for (const site of this._placeCities(capitals)) this._spawnCity(site);
|
|
this._rebuildCityIndex();
|
|
for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) {
|
|
this._spawnUnit(capitals[i], i, this.protoUnits[0]);
|
|
}
|
|
this._recomputeTerritory();
|
|
this._generatePopulation();
|
|
this._initBudgets();
|
|
}
|
|
|
|
_placeCapitals(count) {
|
|
const rng = new Random(this.seed);
|
|
return this._generator.pickCapitals(count, rng);
|
|
}
|
|
|
|
_placeCities(capitals) {
|
|
return this._generator.pickCities(capitals, this.mapConfig.citiesPerCiv, this.mapConfig.citySpacing);
|
|
}
|
|
|
|
_spawnCity(site) {
|
|
const civIndex = site.civIndex;
|
|
if (civIndex < 0 || civIndex >= this.civilisations.length) return;
|
|
const civ = this.civilisations[civIndex];
|
|
const coords = site.coords;
|
|
const isCapital = site.isCapital;
|
|
const description = this._cityDescriptionFor(civ, isCapital, coords);
|
|
const city = {
|
|
id: this._nextCityId,
|
|
civ: civIndex,
|
|
coords,
|
|
name: description.name,
|
|
isCapital,
|
|
population: description.population,
|
|
improvements: description.improvements.slice(),
|
|
buildings: {},
|
|
statuses: [],
|
|
};
|
|
this.cities.push(city);
|
|
this._cityById.set(this._nextCityId, city);
|
|
this._nextCityId += 1;
|
|
}
|
|
|
|
_cityDescriptionFor(civ, _isCapital, center) {
|
|
return this._generatedCityDescription(civ, center);
|
|
}
|
|
|
|
_rebuildCityIndex() {
|
|
this._cityByCoords = new Map();
|
|
this._cityAdjacent = new Map();
|
|
for (const city of this.cities) {
|
|
this._cityByCoords.set(key(city.coords.x, city.coords.y), city);
|
|
const owner = city.civ;
|
|
if (!this._cityAdjacent.has(owner)) this._cityAdjacent.set(owner, new Set());
|
|
const adjacent = this._cityAdjacent.get(owner);
|
|
for (const neighbour of this._neighbours(city.coords)) {
|
|
adjacent.add(key(neighbour.x, neighbour.y));
|
|
}
|
|
}
|
|
}
|
|
|
|
_generatedCityDescription(civ, center) {
|
|
const index = this.civilisations.indexOf(civ);
|
|
const used = this._cityNamesUsed.get(index) || 0;
|
|
this._cityNamesUsed.set(index, used + 1);
|
|
let name = `${civ.name} ${used + 1}`;
|
|
if (used < civ.cityNames.length) name = civ.cityNames[used];
|
|
const population = 500000 +
|
|
(Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000);
|
|
return { name, population, improvements: [] };
|
|
}
|
|
|
|
_spawnUnit(center, civIndex, proto) {
|
|
let index = this.protoUnits.indexOf(proto);
|
|
if (index < 0) {
|
|
this.protoUnits.push(proto);
|
|
index = this.protoUnits.length - 1;
|
|
}
|
|
const unit = {
|
|
id: this._nextUnitId,
|
|
civ: civIndex,
|
|
proto: index,
|
|
coords: center,
|
|
hp: proto.maxHp,
|
|
maxHp: proto.maxHp,
|
|
path: [],
|
|
pathIndex: 0,
|
|
progressHours: 0,
|
|
attackTarget: null,
|
|
statuses: [],
|
|
};
|
|
this.units.push(unit);
|
|
this._unitById.set(this._nextUnitId, unit);
|
|
this._nextUnitId += 1;
|
|
return unit;
|
|
}
|
|
|
|
_recomputeTerritory() {
|
|
this.territory = new Map();
|
|
this._gdpPerCapitaCache = new Map();
|
|
const ownerByCell = new Map();
|
|
let frontier = [];
|
|
for (const city of this.cities) {
|
|
const k = key(city.coords.x, city.coords.y);
|
|
ownerByCell.set(k, city.civ);
|
|
this.territory.set(k, city.civ);
|
|
frontier.push(city.coords);
|
|
}
|
|
while (frontier.length > 0) {
|
|
const next = [];
|
|
for (const coords of frontier) {
|
|
const owner = ownerByCell.get(key(coords.x, coords.y));
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (ownerByCell.has(nk) || !this._isLand(neighbour)) continue;
|
|
ownerByCell.set(nk, owner);
|
|
this.territory.set(nk, owner);
|
|
next.push(neighbour);
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
this._territoryByCiv = new Map();
|
|
for (const [k, owner] of this.territory) {
|
|
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
|
|
this._territoryByCiv.get(owner).push(parseKey(k));
|
|
}
|
|
this._serializedTerritory = serializeCells(this.territory);
|
|
}
|
|
|
|
// Rebuilds the owner index, the serialised territory and the GDP-per-capita
|
|
// cache after `this.territory` changes outside a full recompute.
|
|
_reindexTerritory() {
|
|
this._territoryByCiv = new Map();
|
|
for (const [k, owner] of this.territory) {
|
|
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
|
|
this._territoryByCiv.get(owner).push(parseKey(k));
|
|
}
|
|
this._serializedTerritory = serializeCells(this.territory);
|
|
this._gdpPerCapitaCache = new Map();
|
|
}
|
|
|
|
// Transfers `city` to `newCiv` and, with it, every tile of the old owner's
|
|
// territory that is strictly closer (through land) to the captured city than
|
|
// to any of their remaining cities. Ties stay with the old owner.
|
|
_captureCity(city, newCiv) {
|
|
const oldCiv = city.civ;
|
|
if (oldCiv === newCiv) return false;
|
|
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
|
|
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
|
|
city.civ = newCiv;
|
|
for (const coords of region) {
|
|
const k = key(coords.x, coords.y);
|
|
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
|
|
}
|
|
// The captured city's own tile always changes hands, ties or not.
|
|
this.territory.set(key(city.coords.x, city.coords.y), newCiv);
|
|
this.training.delete(city.id);
|
|
this._reindexTerritory();
|
|
this._rebuildCityIndex();
|
|
this._visibilityDirty = true;
|
|
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
|
|
// Multi-source BFS over land from the captured city and every remaining city
|
|
// of the old owner. Each tile records the civ of the nearest city; a tile
|
|
// reached at the same distance from both sides is credited to the old owner.
|
|
_nearestCityOwner(captured, oldCiv, newCiv) {
|
|
const claim = new Map();
|
|
const visited = new Set();
|
|
let frontier = [];
|
|
const sources = [{ coords: captured.coords, civ: newCiv }];
|
|
for (const city of this.cities) {
|
|
if (city !== captured && city.civ === oldCiv) {
|
|
sources.push({ coords: city.coords, civ: oldCiv });
|
|
}
|
|
}
|
|
for (const source of sources) {
|
|
const k = key(source.coords.x, source.coords.y);
|
|
if (visited.has(k)) continue;
|
|
visited.add(k);
|
|
claim.set(k, source.civ);
|
|
frontier.push(source.coords);
|
|
}
|
|
while (frontier.length > 0) {
|
|
const nextFrontier = [];
|
|
const nextClaim = new Map();
|
|
for (const coords of frontier) {
|
|
const civ = claim.get(key(coords.x, coords.y));
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (visited.has(nk) || !this._isLand(neighbour)) continue;
|
|
if (nextClaim.has(nk)) {
|
|
if (nextClaim.get(nk) === newCiv && civ === oldCiv) nextClaim.set(nk, oldCiv);
|
|
continue;
|
|
}
|
|
nextClaim.set(nk, civ);
|
|
nextFrontier.push(neighbour);
|
|
}
|
|
}
|
|
for (const [k, civ] of nextClaim) {
|
|
claim.set(k, civ);
|
|
visited.add(k);
|
|
}
|
|
frontier = nextFrontier;
|
|
}
|
|
return claim;
|
|
}
|
|
|
|
_generatePopulation() {
|
|
this.tilePopulation = new Map();
|
|
for (const k in this.tiles) {
|
|
if (this._isLand(parseKey(k))) this.tilePopulation.set(k, this._basePopulation(parseKey(k)));
|
|
}
|
|
for (const city of this.cities) {
|
|
this.tilePopulation.set(key(city.coords.x, city.coords.y), city.population);
|
|
}
|
|
}
|
|
|
|
_basePopulation(coords) {
|
|
return Math.round(ECONOMY.basePopulation * this.terrainStats.populationMultiplier(coords));
|
|
}
|
|
|
|
_initBudgets() {
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
this.budgets.set(i, ECONOMY.startingBudget);
|
|
}
|
|
}
|
|
|
|
_isLand(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
return !!tile && tile.terrainClass === "Land";
|
|
}
|
|
|
|
_isNextToCity(coords, owner) {
|
|
const adjacent = this._cityAdjacent.get(owner);
|
|
return adjacent ? adjacent.has(key(coords.x, coords.y)) : false;
|
|
}
|
|
|
|
_tickPopulation() {
|
|
const factor = ECONOMY.populationGrowthFactor(HOURS_PER_YEAR, HOURS_PER_DAY);
|
|
if (factor === 0) return;
|
|
for (const k of Array.from(this.tilePopulation.keys())) {
|
|
this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor));
|
|
}
|
|
}
|
|
|
|
_tickEconomy() {
|
|
const hours = HOURS_PER_DAY;
|
|
for (const civ of Array.from(this.budgets.keys())) {
|
|
const gdp = this.getPlayerGdp(civ);
|
|
const modifiers = this.getCivModifiers(civ);
|
|
const income = ECONOMY.budgetIncome(gdp, hours) * (1 + (modifiers[EFFECT_BUDGET_INCOME] || 0));
|
|
const upkeep = this.getPlayerUpkeep(civ, gdp) * hours;
|
|
this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
|
|
}
|
|
}
|
|
|
|
_tickBuildings() {
|
|
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
|
const modifiers = this.getCivModifiers(civ);
|
|
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
|
|
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
|
|
if (scienceRate === 0 && cultureRate === 0) continue;
|
|
this.research.set(civ, this.getResearch(civ) + scienceRate);
|
|
this.culture.set(civ, this.getCulture(civ) + cultureRate);
|
|
}
|
|
}
|
|
|
|
_tickTraining() {
|
|
const completed = [];
|
|
for (const [cityId, entry] of this.training) {
|
|
entry.elapsedHours += 1;
|
|
const produced = entry.elapsedHours * entry.capacity;
|
|
const proto = this.protoUnits[entry.protoIndex];
|
|
if (produced >= proto.cost) {
|
|
const city = this.findCity(cityId);
|
|
if (city) {
|
|
this._spawnUnit(city.coords, city.civ, proto);
|
|
this._visibilityDirty = true;
|
|
}
|
|
completed.push(cityId);
|
|
}
|
|
}
|
|
for (const cityId of completed) this.training.delete(cityId);
|
|
}
|
|
|
|
_refreshVisibility() {
|
|
this.visible = new Map();
|
|
for (let i = 0; i < this.civilisations.length; i++) this.visible.set(i, new Set());
|
|
for (const [civ, cells] of this._territoryByCiv) {
|
|
for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION);
|
|
}
|
|
for (const unit of this.units) {
|
|
this._reveal(unit.civ, unit.coords, UNIT_VISION);
|
|
}
|
|
}
|
|
|
|
_revealAll() {
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
this.explored.set(i, new Set(Object.keys(this.tiles)));
|
|
}
|
|
}
|
|
|
|
_reveal(civ, center, radius) {
|
|
if (!this.explored.has(civ)) this.explored.set(civ, new Set());
|
|
if (!this.visible.has(civ)) this.visible.set(civ, new Set());
|
|
const exploredCells = this.explored.get(civ);
|
|
const visibleCells = this.visible.get(civ);
|
|
let discovered = false;
|
|
for (const coords of this._cellsInRadius(center, radius)) {
|
|
const k = key(coords.x, coords.y);
|
|
if (!exploredCells.has(k)) {
|
|
exploredCells.add(k);
|
|
discovered = true;
|
|
}
|
|
visibleCells.add(k);
|
|
}
|
|
if (discovered) this._serializedExplored.delete(civ);
|
|
}
|
|
|
|
_cachedExplored(civ) {
|
|
if (!this._serializedExplored.has(civ)) {
|
|
this._serializedExplored.set(civ, serializeSet(this.explored.get(civ) || new Set()));
|
|
}
|
|
return this._serializedExplored.get(civ);
|
|
}
|
|
|
|
_cellsInRadius(center, radius) {
|
|
const result = [center];
|
|
const visited = new Set([key(center.x, center.y)]);
|
|
let frontier = [center];
|
|
for (let step = 0; step < radius; step++) {
|
|
const next = [];
|
|
for (const coords of frontier) {
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const k = key(neighbour.x, neighbour.y);
|
|
if (visited.has(k)) continue;
|
|
visited.add(k);
|
|
next.push(neighbour);
|
|
result.push(neighbour);
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_neighbours(coords) {
|
|
const k = key(coords.x, coords.y);
|
|
if (this._neighbourCache.has(k)) return this._neighbourCache.get(k);
|
|
const result = this.topology.neighbours(coords.x, coords.y);
|
|
this._neighbourCache.set(k, result);
|
|
return result;
|
|
}
|
|
|
|
_isControlledBy(unit, coords) {
|
|
return this.civAt(coords) === unit.civ;
|
|
}
|
|
|
|
_unitSpeed(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return Math.max(proto ? proto.speed : DEFAULT_UNIT_SPEED, 0.0001);
|
|
}
|
|
|
|
_effectiveSpeedAt(unit, coords) {
|
|
let speed = this._unitSpeed(unit);
|
|
if (this._isControlledBy(unit, coords)) speed *= CONTROLLED_SPEED_MULTIPLIER;
|
|
return speed;
|
|
}
|
|
|
|
_tileTravelHours(unit, coords) {
|
|
return this.terrainStats.movementCostMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
|
|
}
|
|
|
|
_fastestTileTravelHours(unit) {
|
|
return this._minMovementCost / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
|
|
}
|
|
|
|
_heuristic(unit, from, to) {
|
|
const delta = this.topology.pixelDelta(from, to);
|
|
const tiles = Math.hypot(delta.x, delta.y) / this._maxStepLength;
|
|
return tiles * this._fastestTileTravelHours(unit);
|
|
}
|
|
|
|
_computeMaxStepLength() {
|
|
let longest = 0;
|
|
for (const neighbour of this._neighbours({ x: 0, y: 0 })) {
|
|
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
|
|
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
|
|
}
|
|
return longest > 0 ? longest : 1.0;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Serialisation
|
|
// -----------------------------------------------------------------------
|
|
|
|
snapshot(viewerCiv = -1) {
|
|
return this.viewerSnapshot(this.serializeShared(), viewerCiv);
|
|
}
|
|
|
|
serializeShared() {
|
|
return {
|
|
seed: this.seed,
|
|
totalHours: this.totalHours,
|
|
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
|
|
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
|
|
buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })),
|
|
governments: this.governments.map((g) => ({ id: g.id, name: g.name })),
|
|
technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })),
|
|
cities: this._serializeCities(),
|
|
units: this._serializeUnits(),
|
|
territory: this._serializedTerritory,
|
|
population: this._serializePopulation(),
|
|
budgets: this._serializeCivFloats(this.budgets),
|
|
research: this._serializeCivFloats(this.research),
|
|
culture: this._serializeCivFloats(this.culture),
|
|
government: this._serializeCivInts(this.government),
|
|
researched: this._serializeResearched(),
|
|
diplomacy: this._serializeDiplomacy(),
|
|
news: this._serializeNews(),
|
|
training: Object.fromEntries(
|
|
Array.from(this.training.entries()).map(([cityId, entry]) => [
|
|
cityId,
|
|
{ ...entry },
|
|
])
|
|
),
|
|
};
|
|
}
|
|
|
|
viewerSnapshot(shared, viewerCiv, stats = null) {
|
|
const result = { ...shared };
|
|
result.viewer = viewerCiv;
|
|
result.viewerStats = stats || this.viewerStats(viewerCiv);
|
|
result.explored = this._cachedExplored(viewerCiv);
|
|
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
|
|
return result;
|
|
}
|
|
|
|
viewerStats(viewerCiv) {
|
|
if (viewerCiv < 0) {
|
|
return {
|
|
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
|
|
upkeep: 0, research: 0, culture: 0, government: 0,
|
|
breakdown: null,
|
|
};
|
|
}
|
|
const aggregates = this._playerAggregates(viewerCiv);
|
|
const population = Math.round(aggregates.population);
|
|
const gdp = aggregates.gdp;
|
|
return {
|
|
population,
|
|
gdp,
|
|
gdpPerCapita: population > 0 ? gdp / population : 0,
|
|
budget: this.getBudget(viewerCiv),
|
|
upkeep: this.getPlayerUpkeep(viewerCiv, gdp),
|
|
research: this.getResearch(viewerCiv),
|
|
culture: this.getCulture(viewerCiv),
|
|
government: this.getGovernment(viewerCiv),
|
|
breakdown: this.budgetBreakdown(viewerCiv),
|
|
};
|
|
}
|
|
|
|
_serializeCities() {
|
|
return this.cities.map((city) => ({
|
|
id: city.id,
|
|
civ: city.civ,
|
|
coords: [city.coords.x, city.coords.y],
|
|
name: city.name,
|
|
isCapital: city.isCapital,
|
|
population: city.population,
|
|
improvements: city.improvements,
|
|
buildings: { ...city.buildings },
|
|
statuses: city.statuses || [],
|
|
}));
|
|
}
|
|
|
|
_serializeUnits() {
|
|
const battle = this._battleUnitIds();
|
|
return this.units.map((unit) => {
|
|
const rawPath = unit.path || [];
|
|
const pathIndex = unit.pathIndex;
|
|
const path = rawPath.map((coords) => [coords.x, coords.y]);
|
|
const segmentHours = [];
|
|
for (let i = 1; i < rawPath.length; i++) {
|
|
segmentHours.push(this._tileTravelHours(unit, rawPath[i]));
|
|
}
|
|
let stepHours = 0;
|
|
if (pathIndex + 1 < rawPath.length) {
|
|
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]);
|
|
}
|
|
return {
|
|
id: unit.id,
|
|
civ: unit.civ,
|
|
proto: unit.proto,
|
|
coords: [unit.coords.x, unit.coords.y],
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp,
|
|
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
|
|
path,
|
|
pathIndex,
|
|
progressHours: unit.progressHours,
|
|
stepHours,
|
|
segmentHours,
|
|
};
|
|
});
|
|
}
|
|
|
|
_serializePopulation() {
|
|
const result = [];
|
|
for (const [k, value] of this.tilePopulation) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y, Math.round(value)]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_serializeCivFloats(values) {
|
|
const result = [];
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
result.push(values.has(i) ? values.get(i) : 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_serializeCivInts(values) {
|
|
const result = [];
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
result.push(values.has(i) ? values.get(i) : 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_serializeResearched() {
|
|
const result = [];
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
const set = this.researched.get(i) || new Set();
|
|
const indices = Array.from(set).sort((a, b) => a - b);
|
|
result.push(indices);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Every unordered pair with its current status. With a handful of
|
|
// civilisations the whole matrix is tiny, and it lets the client show the
|
|
// status of every nation without a second lookup.
|
|
_serializeDiplomacy() {
|
|
const result = [];
|
|
for (let a = 0; a < this.civilisations.length; a++) {
|
|
for (let b = a + 1; b < this.civilisations.length; b++) {
|
|
result.push([a, b, this.getRelation(a, b)]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_serializeNews() {
|
|
return this.news.map((entry) => ({ ...entry }));
|
|
}
|
|
}
|
|
|
|
// Canonical key for an unordered pair of civilisations.
|
|
function warKey(a, b) {
|
|
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
}
|
|
|
|
function effectAt(e, level) {
|
|
if (level <= 0) return 0;
|
|
return e.baseValue * Math.pow(level, e.exponent);
|
|
}
|
|
|
|
function mergeModifiers(totals, additions) {
|
|
for (const stat of Object.keys(additions)) {
|
|
totals[stat] = (totals[stat] || 0) + additions[stat];
|
|
}
|
|
}
|
|
|
|
function serializeCells(map) {
|
|
const result = [];
|
|
for (const [k, value] of map) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y, value]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function serializeSet(set) {
|
|
const result = [];
|
|
for (const k of set) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y, true]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseKey(k) {
|
|
const i = k.indexOf(",");
|
|
return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
|
|
}
|