Refactoring and clean-up

This commit is contained in:
2026-09-17 22:35:42 +02:00
parent 6a4ce1ca5f
commit 168c365b87
15 changed files with 83 additions and 146 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
// Camera, zoom/LOD switching, pointer input, selection and screen-to-tile
// hit-testing.
import { mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { mapToLocal, key, parity, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { clamp, parity } from "./utils.js";
import { clamp } from "./utils.js";
export const inputMethods = {
// ------------------------------------------------------------- camera --
+4 -2
View File
@@ -3,6 +3,7 @@
import { mapToLocal, key } from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
@@ -247,7 +248,8 @@ export const motionMethods = {
const costCache = new Map();
const heuristicCache = new Map();
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
const fastestHours = this.terrainStats.minMovementCostMultiplier() / (bestSpeed * 1.5);
const fastestHours =
this.terrainStats.minMovementCostMultiplier() / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
@@ -398,7 +400,7 @@ export const motionMethods = {
_stepHours(proto, civ, coords, speed = null) {
const k = key(coords.x, coords.y);
let effective = Math.max(speed || proto.speed, 0.0001);
if (this.territory.get(k) === civ) effective *= 1.5;
if (this.territory.get(k) === civ) effective *= CONTROLLED_SPEED_MULTIPLIER;
return this.terrainStats.movementCostMultiplier(coords) / effective;
},
};
-4
View File
@@ -5,10 +5,6 @@
import { HEX_W, HEX_H } from "../../../shared/hex.js";
import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js";
export function parity(x) {
return x & 1;
}
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
// rebuilding a layer when the snapshot did not actually change it.
export function hashEntries(entries) {
+12 -18
View File
@@ -120,14 +120,8 @@ export class GameServer extends EventEmitter {
}
_handleMove(civ, order) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) return false;
units.push(unit.id);
}
if (units.length === 0) return false;
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestGroupMove(units, coordsOf(order.coords));
}
@@ -146,26 +140,26 @@ export class GameServer extends EventEmitter {
return this.state.requestScheduledMove(units, goals, !!order.append);
}
_ownUnitIds(civ, order) {
// The caller's own unit ids. With `strict`, a single id that is unknown or
// owned by another civilisation rejects the whole order (null); otherwise the
// foreign ids are simply dropped.
_ownUnitIds(civ, order, strict = false) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) continue;
if (!unit || unit.civ !== civ) {
if (strict) return null;
continue;
}
units.push(unit.id);
}
return units;
}
_handlePillage(civ, order) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) return false;
units.push(unit.id);
}
if (units.length === 0) return false;
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestPillage(units);
}
-1
View File
@@ -4,7 +4,6 @@
export const RELATION_PEACE = "peace";
export const RELATION_WAR = "war";
export const RELATIONS = [RELATION_PEACE, RELATION_WAR];
// Kinds of news item shown in the feed. Newsworthy events are recorded in
// GameState with their in-game hour so every client can render them.
+16 -43
View File
@@ -73,7 +73,22 @@ export class GameState {
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();
@@ -100,13 +115,6 @@ export class GameState {
this.conflicts = new Map();
this.news = [];
this.totalHours = 0;
this._configured = false;
this._generator = null;
this._pathfinder = new HexPathfinder();
this._minMovementCost = 1.0;
this._maxStepLength = 1.0;
this._nextUnitId = 1;
this._nextCityId = 1;
this._nextNewsId = 1;
@@ -126,8 +134,6 @@ export class GameState {
this._serializedExplored = new Map();
this._neighbourCache = new Map();
this._visibilityDirty = true;
this._listeners = [];
}
onChanged(fn) {
@@ -157,40 +163,7 @@ export class GameState {
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
this._maxStepLength = this._computeMaxStepLength();
this.cities = [];
this.units = [];
this.territory = new Map();
this.tilePopulation = new Map();
this.tileGdpPenalty = new Map();
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.wars = new Set();
this.conflicts = new Map();
this.news = [];
this._nextNewsId = 1;
this._nextUnitId = 1;
this._nextCityId = 1;
this._unitById = new Map();
this._cityById = new Map();
this._cityNamesUsed = new Map();
this._usedCityNames = new Set();
this._cityByCoords = new Map();
this._cityAdjacent = new Map();
this._territoryByCiv = new Map();
this._gdpPerCapitaCache = new Map();
this._populationBalance = new Map();
this._perCapitaBalance = new Map();
this._serializedTerritory = [];
this._serializedExplored = new Map();
this._neighbourCache = new Map();
this.explored = new Map();
this.visible = new Map();
this._resetCollections();
for (let i = 0; i < this.civilisations.length; i++) {
this.research.set(i, 0);
this.culture.set(i, 0);
+3 -3
View File
@@ -7,9 +7,9 @@ export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
export const HOURS_PER_DAY = 24;
export const DAYS_PER_YEAR = 365;
export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR;
// The calendar lives in one place: re-exported so model code can keep
// importing every simulation constant from this module.
export { HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR } from "../game_clock.js";
export const CONTROLLED_SPEED_MULTIPLIER = 1.5;
export const DEFAULT_UNIT_SPEED = 0.5;
export const MIN_STEP_COST = 0.0001;
+8 -10
View File
@@ -9,7 +9,12 @@ import {
EFFECT_CULTURE,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { buildingUpkeep, prerequisitesMet } from "../rules.js";
import {
buildingUpkeep,
governmentEffects,
prerequisitesMet,
technologyEffects,
} from "../rules.js";
import { key } from "../hex.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
@@ -232,13 +237,8 @@ export const economyMethods = {
},
getCivGovernmentModifiers(civ) {
const totals = {};
const resource = this.governments[this.getGovernment(civ)];
if (!resource) return totals;
for (const e of resource.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
}
return totals;
return resource ? governmentEffects(resource) : {};
},
getCivModifiers(civ) {
@@ -293,9 +293,7 @@ export const economyMethods = {
const totals = {};
const set = this.researched.get(civ) || new Set();
for (const index of set) {
for (const e of this.technologies[index].effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
}
mergeModifiers(totals, technologyEffects(this.technologies[index]));
}
return totals;
},
+17 -29
View File
@@ -1,7 +1,7 @@
// Serialisation: turning the live model into the snapshots the server ships to
// each viewer, with fog-of-war applied per civilisation.
import { key, parseKey } from "../hex.js";
import { parseKey } from "../hex.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
@@ -23,10 +23,10 @@ export const serializationMethods = {
territory: this._serializedTerritory,
population: this._serializePopulation(),
civStats: this._serializeCivStats(),
budgets: this._serializeCivFloats(this.budgets),
research: this._serializeCivFloats(this.research),
culture: this._serializeCivFloats(this.culture),
government: this._serializeCivInts(this.government),
budgets: this._serializeCivValues(this.budgets),
research: this._serializeCivValues(this.research),
culture: this._serializeCivValues(this.culture),
government: this._serializeCivValues(this.government),
researched: this._serializeResearched(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
@@ -58,15 +58,11 @@ export const serializationMethods = {
breakdown: null,
};
}
const aggregates = this._playerAggregates(viewerCiv);
const population = Math.round(aggregates.population);
const gdp = aggregates.gdp;
const headline = this._civHeadline(viewerCiv);
return {
population,
gdp,
gdpPerCapita: population > 0 ? gdp / population : 0,
...headline,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv, gdp),
upkeep: this.getPlayerUpkeep(viewerCiv, headline.gdp),
research: this.getResearch(viewerCiv),
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
@@ -132,27 +128,19 @@ export const serializationMethods = {
// One entry per civilisation with its headline economy figures, so the
// diplomacy panel can show every nation's standing without a second lookup.
_serializeCivStats() {
return this.civilisations.map((civ, index) => {
const aggregates = this._playerAggregates(index);
const population = Math.round(aggregates.population);
const gdp = aggregates.gdp;
return {
population,
gdp,
gdpPerCapita: population > 0 ? gdp / population : 0,
};
});
return this.civilisations.map((_, index) => this._civHeadline(index));
},
_serializeCivFloats(values) {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
result.push(values.has(i) ? values.get(i) : 0);
}
return result;
// Population, GDP and GDP per capita for one civilisation: the headline
// figures the diplomacy panel and the viewer's own stats both show.
_civHeadline(civ) {
const aggregates = this._playerAggregates(civ);
const population = Math.round(aggregates.population);
const gdp = aggregates.gdp;
return { population, gdp, gdpPerCapita: population > 0 ? gdp / population : 0 };
},
_serializeCivInts(values) {
_serializeCivValues(values) {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
result.push(values.has(i) ? values.get(i) : 0);
+6 -16
View File
@@ -35,15 +35,17 @@ export const worldMethods = {
const civ = this.civilisations[civIndex];
const coords = site.coords;
const isCapital = site.isCapital;
const description = this._cityDescriptionFor(civ, isCapital, coords);
const name = this._nextCityName(civ, civIndex);
const population = 500000 +
(Math.abs((coords.x * 92837111) ^ (coords.y * 689287499)) % 3500000);
const city = {
id: this._nextCityId,
civ: civIndex,
coords,
name: description.name,
name,
isCapital,
population: description.population,
improvements: description.improvements.slice(),
population,
improvements: [],
buildings: {},
statuses: [],
};
@@ -52,10 +54,6 @@ export const worldMethods = {
this._nextCityId += 1;
},
_cityDescriptionFor(civ, _isCapital, center) {
return this._generatedCityDescription(civ, center);
},
_rebuildCityIndex() {
this._cityByCoords = new Map();
this._cityAdjacent = new Map();
@@ -70,14 +68,6 @@ export const worldMethods = {
}
},
_generatedCityDescription(civ, center) {
const index = this.civilisations.indexOf(civ);
const name = this._nextCityName(civ, index);
const population = 500000 +
(Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000);
return { name, population, improvements: [] };
},
// Picks the civilisation's next city name, skipping any a city elsewhere has
// already taken, and falls back to a numbered name so the whole world never
// has two cities called the same thing.
+8 -6
View File
@@ -30,7 +30,9 @@ export function governmentChangeCost(proto, gdp) {
return proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0);
}
export function governmentEffects(proto) {
// Combined effect totals for a catalogue entry whose effects do not scale
// (governments and technologies are always applied at level 1).
function effectsTotal(proto) {
const totals = {};
for (const e of proto.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectValue(e, 1);
@@ -38,12 +40,12 @@ export function governmentEffects(proto) {
return totals;
}
export function governmentEffects(proto) {
return effectsTotal(proto);
}
export function technologyEffects(proto) {
const totals = {};
for (const e of proto.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectValue(e, 1);
}
return totals;
return effectsTotal(proto);
}
// True when every prerequisite technology is in the researched set. The set
+4 -5
View File
@@ -2,6 +2,8 @@
// movement multiplier of 0 (an unauthored layer, or a non-land tile) falls back
// to a normal cost so a unit is never frozen.
import { key, parseKey } from "./hex.js";
export const DEFAULT_MOVEMENT_COST_MULTIPLIER = 1.0;
export class TerrainStats {
@@ -11,7 +13,7 @@ export class TerrainStats {
}
_value(coords, field) {
const tile = this._tiles[coords.x + "," + coords.y];
const tile = this._tiles[key(coords.x, coords.y)];
return tile ? (tile[field] || 0) : 0;
}
@@ -35,10 +37,7 @@ export class TerrainStats {
if (this._minMovementCost !== undefined) return this._minMovementCost;
let cheapest = Infinity;
for (const k in this._tiles) {
const value = this.movementCostMultiplier({
x: Number(k.slice(0, k.indexOf(","))),
y: Number(k.slice(k.indexOf(",") + 1)),
});
const value = this.movementCostMultiplier(parseKey(k));
if (value < cheapest) cheapest = value;
}
this._minMovementCost = cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER;
+2 -5
View File
@@ -2,6 +2,7 @@
// throughout so tests are fast and their results stable.
import { GameState } from "../../shared/game_state.js";
import { parseKey } from "../../shared/hex.js";
import { MAP_CONFIG, CIVILISATIONS } from "../../shared/data.js";
import { startServer } from "../../server/server.js";
@@ -18,7 +19,6 @@ export async function withServer(fn) {
}
export const SEED = 12345;
export const LAND = "Land";
export const SEA = "Sea";
export function smallConfig(overrides = {}) {
@@ -72,10 +72,7 @@ export function adjacentLand(state, unit) {
export function seaTile(state) {
for (const k in state.tiles) {
if (state.tiles[k].terrainClass === SEA) {
const i = k.indexOf(",");
return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
}
if (state.tiles[k].terrainClass === SEA) return parseKey(k);
}
return null;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { GameServer } from "../server/game_server.js";
import { CIVILISATIONS } from "../shared/data.js";
import { smallConfig, adjacentLand, SEED } from "./framework/helpers.js";
import { adjacentLand, SEED } from "./framework/helpers.js";
function fakeNetwork() {
return {
-1
View File
@@ -7,7 +7,6 @@ import {
cityOf,
unitsOf,
snapshotUnit,
SEED,
} from "./framework/helpers.js";
export class GameStateTest extends TestCase {