Files
Battle-for-Tismo/shared/game_state.js
T
2026-09-17 00:56:59 +02:00

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