Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve. That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone. Bundles the in-progress chart sizing for the central bank panel.
434 lines
16 KiB
JavaScript
434 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 { 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";
|
|
|
|
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();
|
|
// 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;
|
|
|
|
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._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
|
|
);
|