Files
Battle-for-Tismo/shared/game_state.js
T
adrien 0d1995c0bb Stopped a tile-work rebuild from discarding every cached trade walk
The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms.

The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit.

Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
2026-09-23 22:22:50 +02:00

552 lines
22 KiB
JavaScript

// 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<ethnicity, people>.
this.migrations = new Map();
// Signed migration change over the last day, per scope: tile, city and
// civ, each a Map<key, Map<ethnicity, people>>. 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
);