Stopped a tile-work rebuild from discarding every cached trade walk
The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
This commit is contained in:
@@ -79,6 +79,21 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
their strikes are routed explicitly. Mirror the air rules (airport-only
|
||||
rebasing, strike radius, ferry fuel) on both client and server, or prediction
|
||||
and the authoritative state will disagree.
|
||||
- Keep the snapshot path cheap. `_broadcastState` rebuilds the shared snapshot
|
||||
and every viewer's stats whenever the state is dirty (orders, hourly ticks),
|
||||
not only when the economy moves, so a walk that touches every tile (headline
|
||||
population/GDP, ethnic makeup, transport counts, a region's tiles) must be
|
||||
memoised against the counters that actually change it: `_gdpEpoch`
|
||||
(`_clearTileGdpCache`/`_dropTileGdpCache`), `_populationVersion`,
|
||||
`_territoryVersion`, `_modifiersVersion`, `_tileImprovementVersion`. Do not
|
||||
cache a whole enemy/own region's walk on nothing, and do not memoise
|
||||
`getCityEconomy` outside a snapshot -- a test pins that a simulation read
|
||||
reflects a direct `tilePopulation` edit at once.
|
||||
- `DELTA_COLLECTIONS` in `server/game_server.js` pairs each large collection
|
||||
with the `versions` key that reports it (note `tileEthnicity` is versioned as
|
||||
`ethnicity`). If the pair drifts, the whole collection is silently shipped on
|
||||
every broadcast -- the opening production baseline is static, so getting this
|
||||
wrong re-sent thousands of entries ten times a second.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -136,9 +151,12 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
lands mid-tick still sees fresh data. The trade graph is the expensive part — a
|
||||
walk from each city floods the ocean, since a sea lane runs any distance — so
|
||||
it is kept across ticks *and* days. `_tradeGraph` caches each `(city, mode)`
|
||||
walk under a stamp built from the road/railway/work/territory version counters,
|
||||
so any network change rebuilds lazily while a quiet world reuses it; a unit
|
||||
move only drops it while a war is on (a hostile camp can sever a route). This
|
||||
walk under a stamp built from the road/railway, territory and storage-node
|
||||
counters, so any network change rebuilds lazily while a quiet world reuses it;
|
||||
a unit move only drops it while a war is on (a hostile camp can sever a
|
||||
route). The tile-improvement version is deliberately *not* in the stamp: a
|
||||
mine, mill or plant rebuilt in place bumps it many times a day but moves no
|
||||
route, and folding it in threw every city's cached walk away each time. This
|
||||
is what stops a settled world re-flooding every sea lane each day.
|
||||
`GameState.warmUp(days)` runs a fixed number of days without players, while
|
||||
`warmUpToStability({ maxDays })` settles until `pricesAreSteady` (every
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "8042991",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Made producers pay their own way and settled the world to steady prices",
|
||||
"body": "- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.\n- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.\n- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.\n- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).\n- The shipped month report carries its label again, so the budget header and pager name the month."
|
||||
},
|
||||
{
|
||||
"hash": "31683be",
|
||||
"date": "2026-09-23",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Moved tile works into a management panel and put every building on one card",
|
||||
"body": "The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.\n\nThe tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.\n\nAGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed."
|
||||
},
|
||||
{
|
||||
"hash": "7ea5373",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Moved the economy onto resources and surfaced each building's material bill",
|
||||
"body": "Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight."
|
||||
}
|
||||
];
|
||||
|
||||
+16
-13
@@ -11,18 +11,21 @@ import { dateString } from "../shared/game_clock.js";
|
||||
|
||||
// The large collections a delta snapshot may leave out when their version has
|
||||
// not moved since the last one sent to that peer. The viewer keeps the previous
|
||||
// copy until it changes. `cityStats` changes with the economy and `visible`
|
||||
// with the fog, so both are versioned to keep the 10 Hz broadcast small.
|
||||
// copy until it changes. Each entry pairs the collection with the version key
|
||||
// that reports its changes; for `tileEthnicity` the version is named
|
||||
// `ethnicity`. A mismatch here silently ships a whole collection every
|
||||
// broadcast -- the opening production baseline is static, and shipping its
|
||||
// several thousand entries on every 10 Hz snapshot was pure waste.
|
||||
const DELTA_COLLECTIONS = [
|
||||
"territory",
|
||||
"regions",
|
||||
"population",
|
||||
"gdpBaseline",
|
||||
"tileEthnicity",
|
||||
"cityStats",
|
||||
"visible",
|
||||
"resourceGraph",
|
||||
"migrationGraph",
|
||||
["territory", "territory"],
|
||||
["regions", "regions"],
|
||||
["population", "population"],
|
||||
["productionBaseline", "productionBaseline"],
|
||||
["tileEthnicity", "ethnicity"],
|
||||
["cityStats", "cityStats"],
|
||||
["visible", "visible"],
|
||||
["resourceGraph", "resourceGraph"],
|
||||
["migrationGraph", "migrationGraph"],
|
||||
];
|
||||
|
||||
// A wire move order carrying more units than this is split into chunks that
|
||||
@@ -600,8 +603,8 @@ export class GameServer extends EventEmitter {
|
||||
return { ...snapshot, delta: false };
|
||||
}
|
||||
const state = { ...snapshot, delta: true };
|
||||
for (const key of DELTA_COLLECTIONS) {
|
||||
if (previous[key] === versions[key]) delete state[key];
|
||||
for (const [key, versionKey] of DELTA_COLLECTIONS) {
|
||||
if (previous[versionKey] === versions[versionKey]) delete state[key];
|
||||
}
|
||||
const explored = snapshot.explored || [];
|
||||
const sent = this._peerExploredSent.get(peerId) || 0;
|
||||
|
||||
+23
-2
@@ -110,6 +110,9 @@ export class GameState {
|
||||
// attribute population, GDP and tax to the city that develops the land.
|
||||
this.tileRegion = new Map();
|
||||
this._regionByCity = new Map();
|
||||
// The per-city owned tile lists behind `regionTiles`, rebuilt when the
|
||||
// territory or region map changes (see territory.js).
|
||||
this._regionTilesCache = null;
|
||||
// Tiles carrying a road (pre-generated city network, plus anything the
|
||||
// players build).
|
||||
this.roads = new Set();
|
||||
@@ -207,6 +210,10 @@ export class GameState {
|
||||
this._approvalCache = new Map();
|
||||
this._civApprovalCache = new Map();
|
||||
this._approvalMatrixCache = null;
|
||||
// Per-civ and per-city ethnic make-ups, rebuilt only when the population,
|
||||
// ethnicity, territory or migration deltas move (see politics.js).
|
||||
this._civEthnicMakeupCache = new Map();
|
||||
this._cityEthnicMakeupCache = new Map();
|
||||
|
||||
this._nextUnitId = 1;
|
||||
this._nextCityId = 1;
|
||||
@@ -235,9 +242,22 @@ export class GameState {
|
||||
this._cityByCoords = new Map();
|
||||
this._cityAdjacent = new Map();
|
||||
this._territoryByCiv = new Map();
|
||||
this._gdpPerCapitaCache = new Map();
|
||||
this._clearTileGdpCache();
|
||||
// Per-civ population/GDP sums, memoised against the GDP generation,
|
||||
// population version and territory version (see `_basePlayerAggregates`).
|
||||
this._baseAggregatesCache = new Map();
|
||||
// Memoised nation-wide modifier totals, invalidated by _touchModifiers.
|
||||
this._civModifierCache = new Map();
|
||||
// Per-city building modifiers and per-nation government/tech modifiers, read
|
||||
// once per populated tile during the daily economy walk; both invalidated by
|
||||
// `_modifiersVersion`.
|
||||
this._cityBuildingModifierCache = new Map();
|
||||
this._nationalModifierCache = new Map();
|
||||
// Per-region port production and food bonuses, likewise keyed to the
|
||||
// modifier version (buildings and ports only change through it).
|
||||
this._regionSeaBonusCache = new Map();
|
||||
// Per-nation road/railway tile counts, keyed to the network and territory.
|
||||
this._transportCountsCache = 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
|
||||
@@ -250,6 +270,7 @@ export class GameState {
|
||||
this._productionBalance = new Map();
|
||||
this._serializedTerritory = [];
|
||||
this._serializedExplored = new Map();
|
||||
this._serializedVisible = new Map();
|
||||
this._serializedTileEthnicityCache = null;
|
||||
this._neighbourCache = new Map();
|
||||
this._visibilityDirty = true;
|
||||
@@ -462,7 +483,7 @@ export class GameState {
|
||||
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();
|
||||
if (this.totalHours % HOURS_PER_DAY === 0) this._clearTileGdpCache();
|
||||
this._tickMonthly();
|
||||
this._tickTraining();
|
||||
this._tickAirUnits();
|
||||
|
||||
@@ -214,7 +214,7 @@ export const airMethods = {
|
||||
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) + perCapita * 0.1;
|
||||
this.tileBattleProductionDeficit.set(k, deficit);
|
||||
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
}
|
||||
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
|
||||
this._razeCityBuildings(city, amount);
|
||||
@@ -258,7 +258,7 @@ export const airMethods = {
|
||||
}
|
||||
}
|
||||
if (razed) {
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
}
|
||||
return razed;
|
||||
|
||||
@@ -378,7 +378,7 @@ export const combatMethods = {
|
||||
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) +
|
||||
perCapita * BATTLE.productionLossPerDay;
|
||||
this.tileBattleProductionDeficit.set(k, deficit);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
scarred = true;
|
||||
}
|
||||
if (scarred) {
|
||||
@@ -408,7 +408,7 @@ export const combatMethods = {
|
||||
} else {
|
||||
this.tileBattleProductionDeficit.set(k, remaining);
|
||||
}
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+122
-31
@@ -32,6 +32,10 @@ import { tileFoodOutput } from "../resources.js";
|
||||
import { HOURS_PER_DAY, HOURS_PER_YEAR, DAYS_PER_YEAR } from "./constants.js";
|
||||
import { effectAt, mergeModifiers } from "./helpers.js";
|
||||
|
||||
// The neutral port/food bonuses for a regionless tile, shared so the common
|
||||
// inland case never allocates.
|
||||
const NO_SEA_BONUS = { version: -1, portProduction: 0, foodMultiplier: 1 };
|
||||
|
||||
export const economyMethods = {
|
||||
// ------------------------------------------------------ aggregates --
|
||||
|
||||
@@ -56,17 +60,47 @@ export const economyMethods = {
|
||||
|
||||
// The economy exactly as the tiles describe it, before balancing. Every
|
||||
// tile's per-capita figure already carries its region's and its nation's
|
||||
// production modifiers, so the sum needs no further adjustment.
|
||||
// production modifiers, so the sum needs no further adjustment. The walk
|
||||
// touches every populated tile a nation holds, and the headline figures are
|
||||
// read all over the snapshot (diplomacy, the nation bar, the budget), so the
|
||||
// sum is memoised until the population, the territory or any per-tile GDP
|
||||
// figure actually moves -- not merely because a unit took a step, which is
|
||||
// what used to drag this walk onto every 10 Hz broadcast.
|
||||
_basePlayerAggregates(civ) {
|
||||
const stamp = `${this._gdpEpoch}:${this._populationVersion}:${this._territoryVersion}`;
|
||||
const cached = this._baseAggregatesCache.get(civ);
|
||||
if (cached && cached.stamp === stamp) return cached;
|
||||
let population = 0;
|
||||
let baseGdp = 0;
|
||||
const cells = this._territoryByCiv.get(civ) || [];
|
||||
for (const coords of cells) {
|
||||
const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0;
|
||||
// An empty tile contributes nothing and its per-capita figure would be
|
||||
// multiplied by zero anyway, so the walk skips it entirely.
|
||||
if (pop <= 0) continue;
|
||||
population += pop;
|
||||
baseGdp += pop * this.getTileGdpPerCapita(coords);
|
||||
}
|
||||
return { population, gdp: baseGdp };
|
||||
const result = { stamp, population, gdp: baseGdp };
|
||||
this._baseAggregatesCache.set(civ, result);
|
||||
return result;
|
||||
},
|
||||
|
||||
// Drops the whole per-tile GDP memo and stamps a new generation. Every caller
|
||||
// that used to clear `_gdpPerCapitaCache` goes through here so the per-civ
|
||||
// aggregate memo below (which sums those figures) can tell the figures moved.
|
||||
_clearTileGdpCache() {
|
||||
const fresh = new Map();
|
||||
this._gdpPerCapitaCache = fresh;
|
||||
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
|
||||
},
|
||||
|
||||
// Forgets one tile's GDP figure and stamps a new generation, so the aggregate
|
||||
// memo is not left holding a sum that includes the old value.
|
||||
_dropTileGdpCache(k) {
|
||||
const cache = this._gdpPerCapitaCache;
|
||||
cache.delete(k);
|
||||
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
|
||||
},
|
||||
|
||||
getTileGdpPerCapita(coords) {
|
||||
@@ -150,7 +184,7 @@ export const economyMethods = {
|
||||
const perCapita = this.getTileGdpPerCapita(coords);
|
||||
if (perCapita > 0) this.tileProductionBaseline.set(k, perCapita);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
},
|
||||
|
||||
// The flat amount a tile's production per head has gained since January 2000:
|
||||
@@ -168,22 +202,10 @@ export const economyMethods = {
|
||||
|
||||
// The flat production per head a region's ports add to every tile it covers:
|
||||
// 1000 per level for each sea tile beside the city, summed over its ports.
|
||||
// Read once per populated tile per day, so the port scan is memoised with the
|
||||
// food figure below.
|
||||
_regionPortProduction(region) {
|
||||
if (!region) return 0;
|
||||
let bonus = 0;
|
||||
for (const [index, level] of Object.entries(region.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
|
||||
bonus += (proto.productionPerSeaTilePerLevel || 0) * level;
|
||||
}
|
||||
}
|
||||
if (bonus === 0) return 0;
|
||||
let sea = 0;
|
||||
for (const neighbour of this._neighbours(region.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||||
}
|
||||
return bonus * sea;
|
||||
return this._regionSeaBonus(region).portProduction;
|
||||
},
|
||||
|
||||
// The extra food a region's ports bring in, as a multiplier on its tiles'
|
||||
@@ -191,21 +213,43 @@ export const economyMethods = {
|
||||
// beside the port city. Returns 1 when the region has no port, so the common
|
||||
// inland case is free.
|
||||
_regionFoodMultiplier(region) {
|
||||
if (!region) return 1;
|
||||
let bonus = 0;
|
||||
return this._regionSeaBonus(region).foodMultiplier;
|
||||
},
|
||||
|
||||
// Both port bonuses at once, memoised per region against the modifier version
|
||||
// (buildings and ports change only through it). The six neighbouring tiles are
|
||||
// walked once for the pair instead of once per caller.
|
||||
_regionSeaBonus(region) {
|
||||
if (!region) return NO_SEA_BONUS;
|
||||
const fingerprint = cityBuildingsFingerprint(region.buildings);
|
||||
const cached = this._regionSeaBonusCache.get(region.id);
|
||||
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
|
||||
return cached;
|
||||
}
|
||||
let production = 0;
|
||||
let food = 0;
|
||||
for (const [index, level] of Object.entries(region.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
|
||||
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||||
production += (proto.productionPerSeaTilePerLevel || 0) * level;
|
||||
food += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||||
}
|
||||
}
|
||||
if (bonus === 0) return 1;
|
||||
let sea = 0;
|
||||
for (const neighbour of this._neighbours(region.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||||
if (production > 0 || food > 0) {
|
||||
for (const neighbour of this._neighbours(region.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||||
}
|
||||
}
|
||||
return 1 + bonus * sea;
|
||||
const result = {
|
||||
version: this._modifiersVersion,
|
||||
fingerprint,
|
||||
portProduction: production * sea,
|
||||
foodMultiplier: food > 0 ? 1 + food * sea : 1,
|
||||
};
|
||||
this._regionSeaBonusCache.set(region.id, result);
|
||||
return result;
|
||||
},
|
||||
|
||||
getTileGdp(coords) {
|
||||
@@ -244,8 +288,14 @@ export const economyMethods = {
|
||||
return total;
|
||||
},
|
||||
|
||||
// How many road and railway tiles a nation controls.
|
||||
// How many road and railway tiles a nation controls. The count walks the
|
||||
// whole network and is read for every nation's upkeep both in the budget
|
||||
// panel and in the snapshot, so it is memoised until a road, railway or
|
||||
// territory moves. The set sizes are folded in so a direct edit still counts.
|
||||
_transportTiles(civ) {
|
||||
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}:${this._territoryVersion}`;
|
||||
const cached = this._transportCountsCache.get(civ);
|
||||
if (cached && cached.stamp === stamp) return cached.value;
|
||||
const counts = { road: 0, railway: 0 };
|
||||
const sets = [
|
||||
[this.roads, "road"],
|
||||
@@ -256,6 +306,7 @@ export const economyMethods = {
|
||||
if (this.civAt(parseKey(k)) === civ) counts[id] += 1;
|
||||
}
|
||||
}
|
||||
this._transportCountsCache.set(civ, { stamp, value: counts });
|
||||
return counts;
|
||||
},
|
||||
|
||||
@@ -323,6 +374,8 @@ export const economyMethods = {
|
||||
for (const coords of this.regionTiles(city)) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const pop = this.tilePopulation.get(k) || 0;
|
||||
// Empty tiles add nothing to the region and need no per-capita figure.
|
||||
if (pop <= 0) continue;
|
||||
population += pop;
|
||||
gdp += pop * this.getTileGdpPerCapita(coords);
|
||||
}
|
||||
@@ -572,8 +625,17 @@ export const economyMethods = {
|
||||
},
|
||||
|
||||
// The modifiers one city's own buildings give. Improvements only shape the
|
||||
// region their city controls, so a tile's per-capita draws on this.
|
||||
// region their city controls, so a tile's per-capita draws on this. A
|
||||
// per-capita figure is derived for every populated tile each day, so the same
|
||||
// handful of cities would otherwise have their buildings re-summed thousands
|
||||
// of times; the totals are memoised against the modifier version, which every
|
||||
// building change bumps.
|
||||
getCityBuildingModifiers(city) {
|
||||
const fingerprint = cityBuildingsFingerprint(city.buildings);
|
||||
const cached = this._cityBuildingModifierCache.get(city.id);
|
||||
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
|
||||
return cached.value;
|
||||
}
|
||||
const totals = {};
|
||||
for (const indexStr of Object.keys(city.buildings)) {
|
||||
const proto = this.protoBuildings[Number(indexStr)];
|
||||
@@ -582,6 +644,11 @@ export const economyMethods = {
|
||||
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level);
|
||||
}
|
||||
}
|
||||
this._cityBuildingModifierCache.set(city.id, {
|
||||
version: this._modifiersVersion,
|
||||
fingerprint,
|
||||
value: totals,
|
||||
});
|
||||
return totals;
|
||||
},
|
||||
|
||||
@@ -591,11 +658,15 @@ export const economyMethods = {
|
||||
},
|
||||
|
||||
// Government and research reach every region of the nation; only city
|
||||
// buildings are regional.
|
||||
// buildings are regional. Like the per-city totals, this is read once per
|
||||
// populated tile per day, so it is memoised against the modifier version.
|
||||
getNationalModifiers(civ) {
|
||||
const cached = this._nationalModifierCache.get(civ);
|
||||
if (cached && cached.version === this._modifiersVersion) return cached.value;
|
||||
const totals = {};
|
||||
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
|
||||
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
|
||||
this._nationalModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
|
||||
return totals;
|
||||
},
|
||||
|
||||
@@ -757,7 +828,7 @@ export const economyMethods = {
|
||||
// from zero. Other technologies keep whatever they had banked.
|
||||
this._setTechnologyProgress(civ, index, 0);
|
||||
this._clearFocus(civ);
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
@@ -851,6 +922,14 @@ export const economyMethods = {
|
||||
return total;
|
||||
},
|
||||
|
||||
// A region's population with the same balancing factor `getCityEconomy` uses,
|
||||
// but without walking every tile's GDP. Callers that only need the headcount
|
||||
// (food needs, supplier reserves) should use this rather than pay for the
|
||||
// production figures.
|
||||
getCityPopulation(city) {
|
||||
return this.regionPopulation(city) * (this._populationBalance.get(city.civ) || 1);
|
||||
},
|
||||
|
||||
// Pulls `amount` people out of a city's region into a unit, choosing the
|
||||
// tiles at random in proportion to how many people each holds. Returns how
|
||||
// many were actually drawn (never more than the region had).
|
||||
@@ -1029,7 +1108,7 @@ export const economyMethods = {
|
||||
this._releaseEntryBudget(city.civ, entry);
|
||||
const level = this.getCityBuildingLevel(city, entry.protoIndex);
|
||||
city.buildings[entry.protoIndex] = level + 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
},
|
||||
|
||||
@@ -1116,3 +1195,15 @@ export const economyMethods = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// A compact fingerprint of a city's building levels. The modifier and port
|
||||
// memos key on it so a direct edit -- a test raising a museum, a save being
|
||||
// loaded -- is noticed even when the modifier-version counter was not bumped.
|
||||
// Only the handful of actually-set buildings are walked, so it stays cheap.
|
||||
function cityBuildingsFingerprint(buildings) {
|
||||
let fingerprint = "";
|
||||
for (const index in buildings) {
|
||||
fingerprint += `${index}=${buildings[index]};`;
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const orderMethods = {
|
||||
this.getCityBuildingLevel(city, protoIndex) +
|
||||
this._pendingBuildingLevels(cityId, protoIndex);
|
||||
city.buildings[protoIndex] = level + 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -107,7 +107,7 @@ export const orderMethods = {
|
||||
// A free improvement is raised at once.
|
||||
if (cost <= 0) {
|
||||
city.buildings[protoIndex] = level + 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -226,7 +226,7 @@ export const orderMethods = {
|
||||
// the level are lost.
|
||||
if (level <= 1) delete city.buildings[protoIndex];
|
||||
else city.buildings[protoIndex] = level - 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -240,7 +240,7 @@ export const orderMethods = {
|
||||
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
|
||||
if (cost > 0) this._spendBudget(civ, cost, "campaign");
|
||||
this.government.set(civ, index);
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -302,7 +302,7 @@ export const orderMethods = {
|
||||
this.tilePopulation.set(k, population);
|
||||
this._populationVersion += 1;
|
||||
this.tileProductionPenalty.set(k, penalty);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
// The people driven off the tile are civilians lost to its owner in the war
|
||||
// against the pillaging civilisation.
|
||||
if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff);
|
||||
|
||||
@@ -212,9 +212,16 @@ export const politicsMethods = {
|
||||
// balancing factor the economy uses so the counts match the headline
|
||||
// population the rest of the UI shows.
|
||||
getCivEthnicMakeup(civ) {
|
||||
const stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}`;
|
||||
const cached = this._civEthnicMakeupCache.get(civ);
|
||||
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
|
||||
return cached.value;
|
||||
}
|
||||
const scale = this._populationBalance.get(civ) || 1;
|
||||
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
|
||||
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
|
||||
const value = this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
|
||||
this._civEthnicMakeupCache.set(civ, { stamp, deltas: this._migrationDeltas, value });
|
||||
return value;
|
||||
},
|
||||
|
||||
// One ethnicity's share of a civilisation's whole population, 0 when it has
|
||||
@@ -230,11 +237,20 @@ export const politicsMethods = {
|
||||
return total > 0 ? target / total : 0;
|
||||
},
|
||||
|
||||
// The ethnic makeup of one city's region.
|
||||
// The ethnic makeup of one city's region. Like the national figure it is
|
||||
// rebuilt only when the population, ethnicity or territory moves, so the
|
||||
// region walk is not repeated on every snapshot the server broadcasts.
|
||||
getCityEthnicMakeup(city) {
|
||||
const stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}:${this._regionVersion}`;
|
||||
const cached = this._cityEthnicMakeupCache.get(city.id);
|
||||
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
|
||||
return cached.value;
|
||||
}
|
||||
const scale = this._populationBalance.get(city.civ) || 1;
|
||||
const counts = this._ethnicCountsOver(this.regionTiles(city));
|
||||
return this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
|
||||
const value = this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
|
||||
this._cityEthnicMakeupCache.set(city.id, { stamp, deltas: this._migrationDeltas, value });
|
||||
return value;
|
||||
},
|
||||
|
||||
_makeupList(counts, trend, scale = 1) {
|
||||
@@ -849,7 +865,7 @@ export const politicsMethods = {
|
||||
this._setTileEthnicFractions(parseKey(entry.k), shares);
|
||||
}
|
||||
this._populationVersion += 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
// Keep each city's headline population in step with its city tile.
|
||||
for (const city of [fromCity, toCity]) {
|
||||
city.population = Math.round(
|
||||
@@ -923,7 +939,7 @@ export const politicsMethods = {
|
||||
this._redistributePopulation(cities, population, targetByCity);
|
||||
}
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
},
|
||||
|
||||
// Moves people from the regions holding more than their target to those
|
||||
|
||||
@@ -311,7 +311,7 @@ export const resourceMethods = {
|
||||
for (const claim of claims) {
|
||||
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
},
|
||||
|
||||
// The greedy heart of the seeding: each pass covers every resource the nation
|
||||
@@ -375,7 +375,7 @@ export const resourceMethods = {
|
||||
// have added their draw, and the plants themselves add fuel and upkeep.
|
||||
// The GDP figure the ledger reads must reflect those converters, so the
|
||||
// per-tile cache is dropped before it is consulted.
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
const energy = this._resourceSeedEnergy(civ, components, foodEnergy);
|
||||
for (const entry of energy) {
|
||||
const target = entry.deficit * SEED_ENERGY_MARGIN;
|
||||
@@ -1076,13 +1076,50 @@ export const resourceMethods = {
|
||||
// The same daily wear as one materials demand, for the summary panels and the
|
||||
// market-value forecast.
|
||||
getCivUpkeepResources(civ) {
|
||||
const categories = this.getCivUpkeepBreakdown(civ);
|
||||
return this.getCivUpkeepTotals(civ);
|
||||
},
|
||||
|
||||
// The nation's daily wear summed without building the per-category labels,
|
||||
// part lists and merged sources the budget panel needs. The summary and the
|
||||
// HUD upkeep read this several times per broadcast, so they skip that
|
||||
// bookkeeping rather than allocate a breakdown just to add four numbers.
|
||||
getCivUpkeepTotals(civ) {
|
||||
const total = { steel: 0, energy: 0, hightech: 0, food: 0 };
|
||||
for (const costs of Object.values(categories)) {
|
||||
total.steel += costs.steel;
|
||||
total.energy += costs.energy;
|
||||
total.hightech += costs.hightech;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
if (level <= 0) continue;
|
||||
const proto = this.protoBuildings[Number(index)];
|
||||
if (!proto) continue;
|
||||
const costs = this.buildingUpkeepResources(proto, level);
|
||||
total.steel += costs.steel || 0;
|
||||
total.energy += costs.energy || 0;
|
||||
total.hightech += costs.hightech || 0;
|
||||
}
|
||||
}
|
||||
const battle = this._battleUnitIds();
|
||||
for (const unit of this.units) {
|
||||
if (unit.civ !== civ) continue;
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) continue;
|
||||
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
||||
const multiplier = this._statusUpkeepMultiplier(statuses);
|
||||
const costs = this.unitUpkeepResources(proto);
|
||||
total.steel += (costs.steel || 0) * multiplier;
|
||||
total.hightech += (costs.hightech || 0) * multiplier;
|
||||
}
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!proto) continue;
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
total.steel += costs.steel || 0;
|
||||
total.energy += (costs.energy || 0) + (proto.fuelEnergyPerDay || 0);
|
||||
total.hightech += costs.hightech || 0;
|
||||
}
|
||||
const counts = this._transportTiles(civ);
|
||||
total.steel += counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
|
||||
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
|
||||
return total;
|
||||
},
|
||||
|
||||
@@ -2407,12 +2444,16 @@ export const resourceMethods = {
|
||||
// graph is also commodity-independent -- every commodity rides the same roads,
|
||||
// and only its mass per unit scales the energy -- so the caller folds the
|
||||
// commodity's mass in afterwards. The stamp below mixes the counters that move
|
||||
// when a road, railway, work or territory is added or removed or redrawn with
|
||||
// the sizes of the collections themselves, so even a direct edit picks a fresh
|
||||
// stamp and rebuilds lazily; it is all O(1) reads.
|
||||
// when a road, railway or territory is added or removed or redrawn with the
|
||||
// size of the storage-node set, so even a direct edit picks a fresh stamp and
|
||||
// rebuilds lazily; it is all O(1) reads. It deliberately leaves out the tile
|
||||
// improvements and their version: a road, railway or storage work moves the
|
||||
// edges or the nodes, but rebuilding a mine, mill or plant in place -- which
|
||||
// bumps `_tileImprovementVersion` many times a day -- cannot change a single
|
||||
// transport route, and treating it as one threw every city's cached walk away.
|
||||
_tradeGraph(city, nodes, allowSea) {
|
||||
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}` +
|
||||
`:${this._tileImprovementVersion}:${this.tileImprovements.size}:${this._territoryVersion}`;
|
||||
`:${this._territoryVersion}:${this.resourceStock ? this.resourceStock.size : 0}`;
|
||||
if (!this._tradeGraphCache || this._tradeGraphStamp !== stamp) {
|
||||
this._tradeGraphCache = new Map();
|
||||
this._tradeGraphStamp = stamp;
|
||||
@@ -2545,7 +2586,7 @@ export const resourceMethods = {
|
||||
_nodeCityPopulation(city) {
|
||||
const cache = this._nodePopulationCache;
|
||||
if (cache && cache.has(city.id)) return cache.get(city.id);
|
||||
const population = this.getCityEconomy(city).population;
|
||||
const population = this.getCityPopulation(city);
|
||||
if (cache) cache.set(city.id, population);
|
||||
return population;
|
||||
},
|
||||
@@ -2620,7 +2661,7 @@ export const resourceMethods = {
|
||||
const cost = this.constructionResourceCost(proto, level - 1, buildCost);
|
||||
if (!this._payConstructionResources(city.civ, city.coords, cost)) continue;
|
||||
city.buildings[job.index] = level;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
queue.shift();
|
||||
if (queue.length === 0) this.cityRepairQueue.delete(cityId);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { key, parseKey } from "../hex.js";
|
||||
import { OPINION } from "../data/politics.js";
|
||||
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
|
||||
import { RESOURCE_IDS } from "../data/resources.js";
|
||||
import { serializeSet } from "./helpers.js";
|
||||
import { HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
export const serializationMethods = {
|
||||
@@ -140,7 +139,7 @@ export const serializationMethods = {
|
||||
result.viewer = viewerCiv;
|
||||
result.viewerStats = stats || this.viewerStats(viewerCiv);
|
||||
result.explored = this._cachedExplored(viewerCiv);
|
||||
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
|
||||
result.visible = this._cachedVisible(viewerCiv);
|
||||
// What a city is producing is its owner's business: every other viewer sees
|
||||
// an empty queue, so neither the map bar nor the city panel can reveal
|
||||
// whether a foreign city is busy.
|
||||
|
||||
@@ -443,7 +443,7 @@ export const siteMethods = {
|
||||
// A private upgrade borrowed as it spent, so there is no unspent public
|
||||
// budget to hand back.
|
||||
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
this._touchModifiers();
|
||||
this._tileImprovementVersion += 1;
|
||||
} else if (transportImprovement(site.id)) {
|
||||
@@ -471,7 +471,7 @@ export const siteMethods = {
|
||||
this._visibilityDirty = true;
|
||||
}
|
||||
this.constructionSites.delete(k);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import { POPULARITY } from "../data/politics.js";
|
||||
import { tileImprovementById } from "../data/improvements.js";
|
||||
import { serializeCells } from "./helpers.js";
|
||||
|
||||
// Shared empty result for a city with no region, so a lookup never allocates.
|
||||
const EMPTY_TILES = [];
|
||||
|
||||
export const territoryMethods = {
|
||||
civAt(coords) {
|
||||
const k = key(coords.x, coords.y);
|
||||
@@ -35,7 +38,7 @@ export const territoryMethods = {
|
||||
|
||||
_recomputeTerritory() {
|
||||
this.territory = new Map();
|
||||
this._gdpPerCapitaCache = new Map();
|
||||
this._clearTileGdpCache();
|
||||
const ownerByCell = new Map();
|
||||
let frontier = [];
|
||||
for (const city of this.cities) {
|
||||
@@ -123,9 +126,24 @@ export const territoryMethods = {
|
||||
|
||||
// The land tiles a city's region covers that are still owned by the city's
|
||||
// civilisation. A freshly captured city only taxes the land that came with it.
|
||||
// The economy walks these regions many times a day (every city's GDP, energy
|
||||
// draw and food need), and the filter behind it touches every tile, so the
|
||||
// per-city lists are rebuilt only when the territory or the region map moves.
|
||||
// Callers treat the arrays as read-only.
|
||||
regionTiles(city) {
|
||||
const tiles = this._regionByCity.get(city.id) || [];
|
||||
return tiles.filter((coords) => this.civAt(coords) === city.civ);
|
||||
const stamp = `${this._territoryVersion}:${this._regionVersion}`;
|
||||
let cache = this._regionTilesCache;
|
||||
if (!cache || cache.stamp !== stamp) {
|
||||
const byCity = new Map();
|
||||
for (const [cityId, tiles] of this._regionByCity) {
|
||||
const owner = this._cityById.get(cityId);
|
||||
if (!owner) continue;
|
||||
byCity.set(cityId, tiles.filter((coords) => this.civAt(coords) === owner.civ));
|
||||
}
|
||||
cache = { stamp, byCity };
|
||||
this._regionTilesCache = cache;
|
||||
}
|
||||
return cache.byCity.get(city.id) || EMPTY_TILES;
|
||||
},
|
||||
|
||||
_indexTerritory() {
|
||||
@@ -136,7 +154,7 @@ export const territoryMethods = {
|
||||
this._territoryByCiv.get(owner).push(parseKey(k));
|
||||
}
|
||||
this._serializedTerritory = serializeCells(this.territory);
|
||||
this._gdpPerCapitaCache = new Map();
|
||||
this._clearTileGdpCache();
|
||||
// Which tiles a nation holds changes whose people count toward approval,
|
||||
// so a territory redraw invalidates the memoised approval figures.
|
||||
this._touchPolitics();
|
||||
|
||||
@@ -77,6 +77,17 @@ export const visibilityMethods = {
|
||||
return this._serializedExplored.get(civ);
|
||||
},
|
||||
|
||||
// The viewer's currently visible tiles, flattened for the wire. The whole
|
||||
// visible set is rebuilt only on a visibility refresh, which bumps
|
||||
// `_visibleVersion`, so an unchanged fog is not re-walked on every broadcast.
|
||||
_cachedVisible(civ) {
|
||||
const cached = this._serializedVisible.get(civ);
|
||||
if (cached && cached.version === this._visibleVersion) return cached.value;
|
||||
const value = serializeSet(this.visible.get(civ) || new Set());
|
||||
this._serializedVisible.set(civ, { version: this._visibleVersion, value });
|
||||
return value;
|
||||
},
|
||||
|
||||
_cellsInRadius(center, radius) {
|
||||
const result = [center];
|
||||
const visited = new Set([key(center.x, center.y)]);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const warfareMethods = {
|
||||
const k = key(coords.x, coords.y);
|
||||
if (this.trenches.has(k)) return false;
|
||||
this.trenches.add(k);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export const warfareMethods = {
|
||||
}
|
||||
if (!removed) return false;
|
||||
this._tileImprovementVersion += 1;
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._dropTileGdpCache(k);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -332,7 +332,7 @@ export const worldMethods = {
|
||||
const perHead = base.gdp / base.population;
|
||||
this._productionBalance.set(i, perHead > 0 ? this._targetProductionPerHead / perHead : 1);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._clearTileGdpCache();
|
||||
},
|
||||
|
||||
_isLand(coords) {
|
||||
|
||||
@@ -70,7 +70,7 @@ export class GameServerTest extends TestCase {
|
||||
this.assertNull(delta.territory, "unchanged territory is left out");
|
||||
this.assertNull(delta.regions, "unchanged regions are left out");
|
||||
this.assertNull(delta.population, "unchanged population is left out");
|
||||
this.assertNull(delta.gdpBaseline, "the static growth baseline is left out");
|
||||
this.assertNull(delta.productionBaseline, "the static growth baseline is left out");
|
||||
this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out");
|
||||
this.assertNull(delta.cityStats, "unchanged city stats are left out");
|
||||
this.assertNull(delta.visible, "unchanged visibility is left out");
|
||||
|
||||
Reference in New Issue
Block a user