- run_tests.js discovers and runs each *_test.js in its own worker process (--jobs N to cap the pool, --file <name> for a single suite) - GameState caches the deterministic terrain, city sites and road network by (seed, mapConfig, civ count), rebuilding only the mutable per-game state - the snapshot ships mapConfig and the client rebuilds terrain from it, so the DOM tests can use the small map fixture and a non-default config renders right - shortestPaths can stop once every target city is settled
266 lines
7.9 KiB
JavaScript
266 lines
7.9 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 } 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 { orderMethods } from "./game_state/orders.js";
|
|
import { visibilityMethods } from "./game_state/visibility.js";
|
|
import { serializationMethods } from "./game_state/serialization.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;
|
|
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).
|
|
this.roads = new Set();
|
|
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.research = new Map();
|
|
this.culture = new Map();
|
|
this.government = new Map();
|
|
this.researched = 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 = [];
|
|
|
|
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();
|
|
// 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._neighbourCache = new Map();
|
|
this._visibilityDirty = true;
|
|
}
|
|
|
|
onChanged(fn) {
|
|
this._listeners.push(fn);
|
|
}
|
|
|
|
_emitChanged() {
|
|
for (const fn of this._listeners) fn();
|
|
}
|
|
|
|
isConfigured() {
|
|
return this._configured;
|
|
}
|
|
|
|
configure(civs, worldSeed = 0) {
|
|
this.civilisations = civs.slice();
|
|
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
|
|
this._buildTerrain();
|
|
|
|
this._resetCollections();
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
this.research.set(i, 0);
|
|
this.culture.set(i, 0);
|
|
this.government.set(i, 0);
|
|
this.researched.set(i, new Set());
|
|
}
|
|
this.totalHours = 0;
|
|
this._configured = true;
|
|
|
|
this._placeSettlements();
|
|
this._refreshStatuses();
|
|
this._revealAll();
|
|
this._refreshVisibility();
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
this._tickTraining();
|
|
this._tickAirUnits();
|
|
this._tickGarrisonHealing();
|
|
if (this._visibilityDirty) {
|
|
this._refreshVisibility();
|
|
this._visibilityDirty = false;
|
|
}
|
|
if (this.totalHours % HOURS_PER_DAY === 0) {
|
|
this._tickPopulation();
|
|
this._tickBattleTiles();
|
|
this._tickGdpRecovery();
|
|
this._tickEconomy();
|
|
this._refreshStatuses();
|
|
this._applyDailyStatusEffects();
|
|
this._resolveCombatRound();
|
|
}
|
|
this._tickBuildings();
|
|
// 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,
|
|
orderMethods,
|
|
visibilityMethods,
|
|
serializationMethods
|
|
);
|