Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
426 lines
16 KiB
JavaScript
426 lines
16 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 { 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";
|
|
|
|
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();
|
|
// 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;
|
|
|
|
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 production per head (see _balanceStartingEconomy).
|
|
this._populationBalance = new Map();
|
|
this._productionBalance = 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();
|
|
// 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.
|
|
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 production 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) {
|
|
// 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._tickTileWorks();
|
|
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,
|
|
industryMethods,
|
|
budgetMethods,
|
|
moneyMethods,
|
|
taxMethods
|
|
);
|