Files
Battle-for-Tismo/shared/game_state.js
T
adrien 91dfedf7ec Gave regions their own economies and removed the global market
- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.
- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.
- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.
- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.
- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.
- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
2026-09-22 06:41:58 +02:00

417 lines
15 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 { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.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 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._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._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,
budgetMethods,
moneyMethods
);