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