Files
Battle-for-Tismo/shared/game_state.js
T
adrien 3acd2487df Added roads, regional taxation and a reworked budget view
Roads are pre-generated between cities with a greedy geometric spanner and flatten unit movement cost. City improvements now only affect the tiles their region controls, with per-region tax collection and a Cities tab. The budget is a grouped, scrollable table with 25%/year tax and a 10%-of-GDP opening treasury. Also merged the tile info into the city panel, kept conflict and budget expansion state across snapshots, added join notifications, filled border corners, drew battle sides as stacks and made tab overflow scroll.
2026-09-18 07:11:59 +02:00

275 lines
8.4 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 { MapTopology, key } from "./hex.js";
import { MapGenerator } from "./map_generator.js";
import { TerrainStats } from "./terrain_stats.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 { 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.topology = new MapTopology(
this.mapConfig.mapSize,
this.mapConfig.topology === "cylindrical"
);
this._generator = new MapGenerator(this.mapConfig, this.topology);
this._generator.generate(this.seed);
this.tiles = this._generator.tiles;
this.landCells = this._generator.landCells;
this.terrainStats = new TerrainStats(this.tiles);
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
this._maxStepLength = this._computeMaxStepLength();
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._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,
statusMethods,
diplomacyMethods,
orderMethods,
visibilityMethods,
serializationMethods
);