Files
Battle-for-Tismo/shared/game_state.js
T
adrien 8a7db56b59 Added the approval summary and fixed the panel layout and propaganda curve
- the nation summary shows own-government approval, white in the neutral 40-60% band, red below and green above
- the city and government panels keep a fixed size per tab, dock below the summary and no longer cover it, and their tab bar wraps instead of being clipped
- propaganda campaigns grow exponentially harder near their opinion ceiling or floor, so the last percent are the hardest to win
- memoised city, nation and matrix approval against the politics version, invalidated by territory redraws, so snapshots stop recomputing it every frame
- extracted the duplicated modal tab wiring into modals/tabs.js
- tests: campaign-difficulty, approval-colour and cache-invalidation cases (513 pass)
2026-09-19 00:36:06 +02:00

301 lines
9.7 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 { 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";
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).
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 = [];
// 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;
// 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();
// 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._tickStrikes();
this._tickGarrisonHealing();
if (this._visibilityDirty) {
this._refreshVisibility();
this._visibilityDirty = false;
}
if (this.totalHours % HOURS_PER_DAY === 0) {
this._tickPopulation();
this._tickPolitics();
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,
politicsMethods,
orderMethods,
visibilityMethods,
serializationMethods
);