// Authoritative game model. It owns the generated world, cities, units, // territory, population, the economy, combat and diplomacy, 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. // // The behaviour is split across the modules in ./game_state/ and composed onto // the prototype below. Everything still lives on the one GameState object, so // methods can freely call each other through `this`; the split is purely about // keeping each concern in a file small enough to read. import { key } from "./hex.js"; import { HexPathfinder } from "./hex_pathfinder.js"; import { randomSeed, Random } from "./rng.js"; import { PROTO_UNITS, BUILDINGS, GOVERNMENTS, TECHNOLOGIES, MAP_CONFIG, } from "./data.js"; import { MAX_NEWS, UNIT_VISION, TERRITORY_VISION, HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR, CONTROLLED_SPEED_MULTIPLIER, DEFAULT_UNIT_SPEED, MIN_STEP_COST, TRAINING_QUEUE_LIMIT, DEFENDER_DEFENSE_MULTIPLIER, } from "./game_state/constants.js"; import { worldMethods } from "./game_state/world.js"; import { territoryMethods } from "./game_state/territory.js"; import { economyMethods } from "./game_state/economy.js"; import { movementMethods } from "./game_state/movement.js"; import { combatMethods } from "./game_state/combat.js"; import { airMethods } from "./game_state/air.js"; import { statusMethods } from "./game_state/statuses.js"; import { diplomacyMethods } from "./game_state/diplomacy.js"; import { politicsMethods } from "./game_state/politics.js"; import { orderMethods } from "./game_state/orders.js"; import { visibilityMethods } from "./game_state/visibility.js"; import { serializationMethods } from "./game_state/serialization.js"; import { monthlyMethods } from "./game_state/monthly.js"; import { warfareMethods } from "./game_state/warfare.js"; import { resourceMethods } from "./game_state/resources.js"; import { siteMethods } from "./game_state/sites.js"; import { industryMethods } from "./game_state/industry.js"; import { budgetMethods } from "./game_state/budget.js"; import { moneyMethods } from "./game_state/money.js"; import { taxMethods } from "./game_state/taxes.js"; // Shared empty result for `unitsAt`, so an empty tile does not allocate. const EMPTY_UNITS = []; export { MAX_NEWS, UNIT_VISION, TERRITORY_VISION, HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR, CONTROLLED_SPEED_MULTIPLIER, DEFAULT_UNIT_SPEED, MIN_STEP_COST, TRAINING_QUEUE_LIMIT, DEFENDER_DEFENSE_MULTIPLIER, }; 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.totalHours = 0; this._configured = false; // Set by the server for a local test game: construction and training may be // issued for free and instantly. Never trusted from a remote client. this.testing = false; this._generator = null; this._pathfinder = new HexPathfinder(); this._minMovementCost = 1.0; this._maxStepLength = 1.0; this._listeners = []; this._resetCollections(); } // Recreates every per-game collection, index and counter. Shared by the // constructor and `configure`, so a fresh instance and a restarted game // always start from exactly the same shape. _resetCollections() { this.cities = []; this.units = []; this.territory = new Map(); // Which city's region each land tile belongs to: the nearest city, used to // attribute population, GDP and tax to the city that develops the land. this.tileRegion = new Map(); this._regionByCity = new Map(); // The per-city owned tile lists behind `regionTiles`, rebuilt when the // territory or region map changes (see territory.js). this._regionTilesCache = null; // Tiles carrying a road (pre-generated city network, plus anything the // players build). this.roads = new Set(); // Tiles carrying a railway. A tile holds a road or a railway, never both. this.railways = new Set(); // War-time tile works: trenches dug by infantry, and military improvements // (fortifications, radar towers, coastal cannons) with their owner and // remaining hit points. A tile works queue per civilisation advances them. this.trenches = new Set(); this.tileImprovements = new Map(); this.tileImprovementOwner = new Map(); this.tileImprovementHp = new Map(); // Tile improvements under construction, as economic nodes keyed by tile: the // site gathers materials and builds before the improvement stands. Each // holds its own budget and the prices locked when the work was decided. this.constructionSites = new Map(); // Money left over from a finished work, held on its tile as the // improvement's own cash. this.improvementCash = new Map(); // Production buildings as independent private-sector agents, keyed by tile: // each keeps its own cash, upgrade level and sales history. this.buildingAgents = new Map(); this._tileImprovementVersion = 0; // Bumped whenever the transport network changes, so the serialised road and // railway lists are rebuilt only when something actually moved. this._improvementVersion = 0; this.tilePopulation = new Map(); // Per-tile production-per-head multiplier, lowered by pillaging (default 1). this.tileProductionPenalty = new Map(); // Absolute production per head lost to fighting on a tile, and the hour its // recovery may begin. A tile leaves both maps once it has recovered. this.tileBattleProductionDeficit = new Map(); this.tileBattleRecoveryStart = new Map(); this.budgets = new Map(); this.training = new Map(); this.culture = new Map(); this.government = new Map(); this.researched = new Map(); // Focused research: each nation works on one technology at a time and every // point it creates flows straight into that technology. There is no bank: a // nation with no focus simply does not advance. `techProgress` keeps the // points committed to every technology the nation has started, so switching // focus never loses work already done, and `repeatCounts` counts how many // times each repeatable technology has been researched. this.focusTechnology = new Map(); this.techProgress = new Map(); this.repeatCounts = 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(); // One record per war, keyed like `wars`: when it started and the military // and civilian casualties each side has suffered so far. this.conflicts = new Map(); this.news = []; // Politics: the ethnic makeup of every tile, the opinions each ethnicity // holds of every other ethnicity and government, the war crime grudges a // government carries, its active propaganda campaigns and minority // policies, and small caches/indexes the daily politics tick leans on. this.tileEthnicity = new Map(); this.ethnicOpinions = new Map(); this.govOpinions = new Map(); this.grudges = new Map(); this.campaigns = []; this.policies = new Map(); // The day's migration flows, rebuilt each migration tick: civ -> { inbound, // outbound } as Map. this.migrations = new Map(); // Signed migration change over the last day, per scope: tile, city and // civ, each a Map>. Drives the up/down arrows. this._migrationDeltas = { civ: new Map(), city: new Map(), tile: new Map() }; this._nextCampaignId = 1; this._nextPolicyId = 1; this._rebellionCooldown = new Map(); this._politicsVersion = 0; // Monotonic counters for the large per-tile collections. Snapshots ship // them so a viewer can tell which collections actually changed and rebuild // only those, and the server can omit the unchanged ones from the wire. this._territoryVersion = 0; this._regionVersion = 0; this._populationVersion = 0; this._ethnicityVersion = 0; this._visibleVersion = 0; // Each land tile's opening production per head, the constant natural growth // is measured from. Static once the world exists. this._productionBaselineVersion = 0; this._serializedProductionBaselineCache = null; this.tileProductionBaseline = new Map(); // Approval is expensive to weight over every tile and is read far more // often than politics changes, so both the per-city and per-nation results // are memoised against the politics version (and cleared on territory // changes). The full approval matrix the snapshot ships is cached too. this._approvalCache = new Map(); this._civApprovalCache = new Map(); this._approvalMatrixCache = null; // Per-civ and per-city ethnic make-ups, rebuilt only when the population, // ethnicity, territory or migration deltas move (see politics.js). this._civEthnicMakeupCache = new Map(); this._cityEthnicMakeupCache = new Map(); this._nextUnitId = 1; this._nextCityId = 1; this._nextNewsId = 1; this._cityNamesUsed = new Map(); this._usedCityNames = new Set(); this._unitById = new Map(); this._unitsByTile = new Map(); // Per-civ "tiles held by hostile troops", rebuilt the moment a unit is // spawned, moved or killed, or a war begins; read many times a day. this._hostileOccupiedCache = new Map(); // Per-(city, mode) trade reachability and the port-access memo. The graph is // expensive to walk (sea lanes run any distance, so a walk floods the ocean), // so it is kept across the day's sourcing passes *and* across days, and only // thrown away when the transport network itself changes. `_tradeGraphStamp` // records which network version the cache holds; roads, railways, storage // buildings and territory each bump their own counter, so any change picks a // fresh stamp and rebuilds lazily. Unit movement only matters while a war is // on (a hostile camp severs a route), see `_invalidateUnitCaches`. this._tradeGraphCache = null; this._tradeGraphStamp = null; this._marketAccessCache = null; // Per-city populations, read once for the day's surplus checks. this._nodePopulationCache = null; this._cityById = new Map(); this._cityByCoords = new Map(); this._cityAdjacent = new Map(); this._territoryByCiv = new Map(); this._clearTileGdpCache(); // Per-civ population/GDP sums, memoised against the GDP generation, // population version and territory version (see `_basePlayerAggregates`). this._baseAggregatesCache = new Map(); // Memoised nation-wide modifier totals, invalidated by _touchModifiers. this._civModifierCache = new Map(); // Per-city building modifiers and per-nation government/tech modifiers, read // once per populated tile during the daily economy walk; both invalidated by // `_modifiersVersion`. this._cityBuildingModifierCache = new Map(); this._nationalModifierCache = new Map(); // Per-region port production and food bonuses, likewise keyed to the // modifier version (buildings and ports only change through it). this._regionSeaBonusCache = new Map(); // Per-nation road/railway tile counts, keyed to the network and territory. this._transportCountsCache = new Map(); this._modifiersVersion = 0; // Per-broadcast memo of `getCityEconomy`, active only while the server is // assembling one snapshot (see beginSnapshotCache). Kept separate from the // simulation paths so an hourly tick can never read a stale figure. this._cityEconomyCache = new Map(); this._cityEconomyCacheActive = false; // Per-civ factors that map generation picked so every nation starts with // the same population and production per head (see _balanceStartingEconomy). this._populationBalance = new Map(); this._productionBalance = new Map(); this._serializedTerritory = []; this._serializedExplored = new Map(); this._serializedVisible = new Map(); this._serializedTileEthnicityCache = null; this._neighbourCache = new Map(); this._visibilityDirty = true; // Month-over-month baseline for the nation summary bar; null until the // world exists and January 2000 has a month to compare against. this.monthlyStats = null; this._monthIndexNow = 0; this._currentMonthStats = null; } onChanged(fn) { this._listeners.push(fn); } _emitChanged() { for (const fn of this._listeners) fn(); } // Scopes the per-broadcast city-economy memo. The server brackets one // `serializeShared` plus every viewer's stats in these calls; outside them // `getCityEconomy` always derives a fresh figure, so an hourly simulation // tick can never read a value cached for a snapshot. beginSnapshotCache() { this._cityEconomyCache.clear(); this._cityEconomyCacheActive = true; } endSnapshotCache() { this._cityEconomyCacheActive = false; this._cityEconomyCache.clear(); } isConfigured() { return this._configured; } configure(civs, worldSeed = 0) { this.civilisations = civs.slice(); // The map seeds one small island per nation (up to the map's island // budget), and those islands are the first capitals, so generation has to // know how many nations are playing. The figure travels in the snapshot so // the browser rebuilds the very same islands. this.mapConfig = { ...this.mapConfig, nationCount: this.civilisations.length }; this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed(); // The small islands become the first capitals, so a nation's position in // this list decides whether it starts island-bound. Shuffle the countries // from the world seed so the same few nations are not always the ones // seeded onto the islands. Players pick their nation from this same order, // and the snapshot ships it, so client and server stay in step. new Random((this.seed ^ 0x1b873593) >>> 0).shuffle(this.civilisations); // Combat rolls (a city losing a building level to a bombardment) draw from // their own generator, seeded from the world so a game is reproducible. this._combatRng = new Random((this.seed ^ 0x5bd1e995) >>> 0); this._buildTerrain(); this._resetCollections(); for (let i = 0; i < this.civilisations.length; i++) { this.culture.set(i, 0); this.government.set(i, 0); this.researched.set(i, new Set()); this.focusTechnology.set(i, -1); this.techProgress.set(i, new Map()); this.repeatCounts.set(i, new Map()); } this.totalHours = 0; this._configured = true; this._placeSettlements(); this._initResources(); this._initMoney(); this._initTaxes(); this._refreshStatuses(); this._revealAll(); this._refreshVisibility(); this._initMonthly(); this._initNaturalProductionGrowth(); this._visibilityDirty = false; this._emitChanged(); } // ------------------------------------------------------------- lookups -- 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; } // A seeded [0, 1) roll for the simulation's combat effects. Reproducible from // the world seed, so replaying a game gives the same bombardment results. _random() { if (!this._combatRng) this._combatRng = new Random((this.seed ^ 0x5bd1e995) >>> 0); return this._combatRng.float(); } // Every unit standing on `coords`. Friendly units may stack, so a tile can // hold more than one. Backed by a per-tile index so the many supply, siege and // battle checks that ask "who is on this tile" never scan the whole army. unitsAt(coords) { const bucket = this._unitsByTile && this._unitsByTile.get(key(coords.x, coords.y)); if (bucket) return bucket; return EMPTY_UNITS; } _indexUnit(unit) { if (!this._unitsByTile) this._unitsByTile = new Map(); this._invalidateUnitCaches(); const k = key(unit.coords.x, unit.coords.y); let bucket = this._unitsByTile.get(k); if (!bucket) { bucket = []; this._unitsByTile.set(k, bucket); } bucket.push(unit); } _unindexUnit(unit) { if (!this._unitsByTile) return; this._invalidateUnitCaches(); const k = key(unit.coords.x, unit.coords.y); const bucket = this._unitsByTile.get(k); if (!bucket) return; const index = bucket.indexOf(unit); if (index >= 0) bucket.splice(index, 1); if (bucket.length === 0) this._unitsByTile.delete(k); } // Drops the memos that depend on where units stand: which tiles hostile // troops hold, the trade routes they sever and whether a port is blockaded. _invalidateSpatialCaches() { if (this._hostileOccupiedCache) this._hostileOccupiedCache.clear(); if (this._tradeGraphCache) { this._tradeGraphCache.clear(); this._tradeGraphStamp = null; } if (this._marketAccessCache) this._marketAccessCache.clear(); } // The narrower drop for a unit spawning, moving or dying. Hostile-held tiles // and port access depend on where troops stand, so those memos go. The trade // graph does not: it only reads unit positions through `_hostileOccupied`, // which is empty unless a war is on, so a move in peacetime leaves the cached // walks valid. While a war is on, any move may sever a route, so drop it then. _invalidateUnitCaches() { if (this._hostileOccupiedCache) this._hostileOccupiedCache.clear(); if (this._marketAccessCache) this._marketAccessCache.clear(); if (this._tradeGraphCache && this.wars && this.wars.size > 0) { this._tradeGraphCache.clear(); this._tradeGraphStamp = null; } } // Moves a unit to a new tile and keeps the per-tile index in step. _moveUnitTo(unit, coords) { this._unindexUnit(unit); unit.coords = coords; this._indexUnit(unit); } unitAt(coords) { return this.unitsAt(coords)[0] || null; } // The hostile units standing on `coords` (when seen by `civ`). enemyUnitsAt(coords, civ) { return this.unitsAt(coords).filter((unit) => unit.civ !== civ); } advanceHour() { this.advanceMovement(1.0); this.tickHour(); } // Runs `days` of the world as fast as the machine allows, using the very same // `advanceHour` the live clock runs, so a fresh game can open settled instead // of at a cold start. Nothing changes the transport network while the settle // runs, so the trade-graph cache (see `_tradeGraph`) is simply reused across // every day; a settle would otherwise re-flood every sea lane daily. warmUp(days) { const hours = Math.max(0, Math.floor(days)) * HOURS_PER_DAY; if (hours <= 0) return; try { for (let hour = 0; hour < hours; hour++) this.advanceHour(); } finally { this._resetSettleCaches(); } } // The caches a settle leaves stale: the trade graph is kept across the run // (nothing alters the network), so it is dropped only once the settle ends. _resetSettleCaches() { this._tradeGraphCache = null; this._tradeGraphStamp = null; this._marketAccessCache = null; } tickHour() { this.totalHours += 1; // Natural growth is a whole number of days, so the cached production figures // only go stale when a new day begins. if (this.totalHours % HOURS_PER_DAY === 0) this._clearTileGdpCache(); this._tickMonthly(); this._tickTraining(); this._tickAirUnits(); this._tickStrikes(); this._tickGarrisonHealing(); // Popularity drifts toward neutral every hour, not just daily. this._tickOpinionDrift(); if (this._visibilityDirty) { this._refreshVisibility(); this._visibilityDirty = false; } if (this.totalHours % HOURS_PER_DAY === 0) { // A fresh day of shortage notices: this day's are published to the owners. if (this._resourceAlerts) this._resourceAlerts = new Map(); this._tickPopulation(); this._tickPolitics(); this._tickBattleTiles(); this._tickProductionRecovery(); this._tickMoney(); this._tickEconomy(); this._tickResources(); this._tickRepairs(); this._tickUnitSupply(); this._refreshStatuses(); this._applyDailyStatusEffects(); this._resolveCombatRound(); } this._tickBuildings(); this._tickConstructionSites(); this._tickIndustry(); this._tickEmbark(); this._tickCoastalCannons(); // A unit no longer sharing a tile with an enemy is not an invader any more. for (const unit of this.units) { if (unit.invader && !this._hasHostileOn(unit.coords, unit.civ)) unit.invader = false; } this._emitChanged(); } } // Compose the behaviour mixins onto the prototype. Each module owns one concern // and may call methods from any other through `this`. Object.assign( GameState.prototype, worldMethods, territoryMethods, economyMethods, movementMethods, combatMethods, airMethods, statusMethods, diplomacyMethods, politicsMethods, orderMethods, visibilityMethods, serializationMethods, monthlyMethods, warfareMethods, resourceMethods, siteMethods, industryMethods, budgetMethods, moneyMethods, taxMethods );