GDP per capita had no natural growth, so a nation's economy only moved when population, buildings or war did. The summary stats also offered inconsistent hover text: a native browser tooltip for popularity and none at all for the budget. - Every land tile now gains a constant 0.01% of the GDP per capita it opened with, once a day. Because the amount is fixed the relative growth rate tapers over the years. Each tile's opening figure is snapshotted once and shipped to the browser, so the tile panel and the GDP-per-capita map mode stay in step with the server. - The population, GDP and GDP/capita tooltips show the relative growth: against 1 January 2000 through the first year, then year on year. The monthly captures now keep a rolling thirteen months for the baseline. - Every summary stat explains itself through the same custom bubble. Popularity's native title moved onto it, and Budget forecasts the next calendar month at the current hourly net, as a compact figure such as "+€3.28B/mo" (hoursInNextMonth follows the real calendar). - The tile GDP breakdown shows gains with a plus sign (natural growth, ports) rather than the old minus. Added tests for the constant daily gain, the falling relative rate, the first-year and year-on-year baselines, the snapshot baseline, the budget forecast and the calendar month length.
392 lines
14 KiB
JavaScript
392 lines
14 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";
|
|
|
|
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();
|
|
// 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();
|
|
this.tileWorks = 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 GDP-per-capita multiplier, lowered by pillaging (default 1).
|
|
this.tileGdpPenalty = new Map();
|
|
// Absolute GDP-per-capita lost to fighting on a tile, and the hour its
|
|
// recovery may begin. A tile leaves both maps once it has recovered.
|
|
this.tileBattleGdpDeficit = 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 GDP per capita, the constant natural growth is
|
|
// measured from. Static once the world exists.
|
|
this._gdpBaselineVersion = 0;
|
|
this._serializedGdpBaselineCache = null;
|
|
this.tileGdpBaseline = 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;
|
|
|
|
this._nextUnitId = 1;
|
|
this._nextCityId = 1;
|
|
this._nextNewsId = 1;
|
|
this._cityNamesUsed = new Map();
|
|
this._usedCityNames = new Set();
|
|
this._unitById = new Map();
|
|
this._cityById = new Map();
|
|
this._cityByCoords = new Map();
|
|
this._cityAdjacent = new Map();
|
|
this._territoryByCiv = new Map();
|
|
this._gdpPerCapitaCache = new Map();
|
|
// Memoised nation-wide modifier totals, invalidated by _touchModifiers.
|
|
this._civModifierCache = 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 GDP per capita (see _balanceStartingEconomy).
|
|
this._populationBalance = new Map();
|
|
this._perCapitaBalance = new Map();
|
|
this._serializedTerritory = [];
|
|
this._serializedExplored = 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();
|
|
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
|
|
// 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._refreshStatuses();
|
|
this._revealAll();
|
|
this._refreshVisibility();
|
|
this._initMonthly();
|
|
this._initNaturalGdpGrowth();
|
|
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.
|
|
unitsAt(coords) {
|
|
const result = [];
|
|
for (const unit of this.units) {
|
|
if (unit.coords.x === coords.x && unit.coords.y === coords.y) result.push(unit);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
tickHour() {
|
|
this.totalHours += 1;
|
|
// Natural growth is a whole number of days, so the cached per-capita figures
|
|
// only go stale when a new day begins.
|
|
if (this.totalHours % HOURS_PER_DAY === 0) this._gdpPerCapitaCache.clear();
|
|
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) {
|
|
this._tickPopulation();
|
|
this._tickPolitics();
|
|
this._tickBattleTiles();
|
|
this._tickGdpRecovery();
|
|
this._tickEconomy();
|
|
this._refreshStatuses();
|
|
this._applyDailyStatusEffects();
|
|
this._resolveCombatRound();
|
|
}
|
|
this._tickBuildings();
|
|
this._tickTileWorks();
|
|
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
|
|
);
|