Files
Battle-for-Tismo/shared/game_state/territory.js
T
adrien 0d1995c0bb 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.
2026-09-23 22:22:50 +02:00

252 lines
9.4 KiB
JavaScript

// Territory: which civilisation owns each land tile, how ownership spreads
// from cities, and how it is redrawn when a city changes hands.
import { key, parseKey } from "../hex.js";
import { NEWS_CAPTURE } from "../data/relations.js";
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);
return this.territory.has(k) ? this.territory.get(k) : -1;
},
terrainTypeAt(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return tile ? tile.terrainType : "Unknown";
},
getTilePopulation(coords) {
return Math.round(this.tilePopulation.get(key(coords.x, coords.y)) || 0);
},
getTileInfo(coords) {
const city = this.cityAt(coords);
return {
coords,
owner: this.civAt(coords),
terrainType: this.terrainTypeAt(coords),
population: this.getTilePopulation(coords),
improvements: city ? city.improvements : [],
};
},
_recomputeTerritory() {
this.territory = new Map();
this._clearTileGdpCache();
const ownerByCell = new Map();
let frontier = [];
for (const city of this.cities) {
const k = key(city.coords.x, city.coords.y);
ownerByCell.set(k, city.civ);
this.territory.set(k, city.civ);
frontier.push(city.coords);
}
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const owner = ownerByCell.get(key(coords.x, coords.y));
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (ownerByCell.has(nk) || !this._isLand(neighbour)) continue;
ownerByCell.set(nk, owner);
this.territory.set(nk, owner);
next.push(neighbour);
}
}
frontier = next;
}
this._rebuildRegionIndex();
this._indexTerritory();
},
// The tiles a nation's fortifications anchor: a fort's own tile and its
// neighbours. Even if an enemy takes the nearby city, these tiles stay.
_fortifiedControl(civ) {
const anchored = new Set();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.permanentControl) continue;
if (this.tileImprovementOwner.get(k) !== civ) continue;
anchored.add(k);
for (const neighbour of this._neighbours(parseKey(k))) {
anchored.add(key(neighbour.x, neighbour.y));
}
}
return anchored;
},
// Subdivides each nation's territory into the regions of its own cities: a
// multi-source BFS grown from every city, but never crossing into another
// nation's land. Ties between equally close cities are settled by city order,
// so the result is deterministic and identical on both sides of the wire.
_rebuildRegionIndex() {
this._regionVersion += 1;
this.tileRegion = new Map();
this._regionByCity = new Map();
const cityByCell = new Map();
let frontier = [];
for (const city of this.cities) {
const k = key(city.coords.x, city.coords.y);
cityByCell.set(k, city);
this.tileRegion.set(k, city.id);
frontier.push(city.coords);
}
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const city = cityByCell.get(key(coords.x, coords.y));
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (cityByCell.has(nk) || !this._isLand(neighbour)) continue;
if (this.territory.get(nk) !== city.civ) continue;
cityByCell.set(nk, city);
this.tileRegion.set(nk, city.id);
next.push(neighbour);
}
}
frontier = next;
}
for (const [k, cityId] of this.tileRegion) {
if (!this._regionByCity.has(cityId)) this._regionByCity.set(cityId, []);
this._regionByCity.get(cityId).push(parseKey(k));
}
},
// The city whose region the tile belongs to, or null on unclaimed land.
regionCityAt(coords) {
const id = this.tileRegion.get(key(coords.x, coords.y));
return id !== undefined ? this._cityById.get(id) || null : null;
},
// 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 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() {
this._territoryVersion += 1;
this._territoryByCiv = new Map();
for (const [k, owner] of this.territory) {
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
this._territoryByCiv.get(owner).push(parseKey(k));
}
this._serializedTerritory = serializeCells(this.territory);
this._clearTileGdpCache();
// Which tiles a nation holds changes whose people count toward approval,
// so a territory redraw invalidates the memoised approval figures.
this._touchPolitics();
},
// Rebuilds the owner index, the regions, the serialised territory and the
// GDP-per-capita cache after `this.territory` changes outside a full
// recompute.
_reindexTerritory() {
this._rebuildRegionIndex();
this._indexTerritory();
},
// Transfers `city` to `newCiv` and, with it, every tile of the old owner's
// territory that is strictly closer (through land) to the captured city than
// to any of their remaining cities. Ties stay with the old owner.
_captureCity(city, newCiv) {
const oldCiv = city.civ;
if (oldCiv === newCiv) return false;
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
// Fortifications hold a permanent area of control: their tile and the ring
// around it stay with the builder even when the city nearby changes hands.
const anchored = this._fortifiedControl(oldCiv);
city.civ = newCiv;
for (const coords of region) {
const k = key(coords.x, coords.y);
if (anchored.has(k)) continue;
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
}
// The captured city's own tile always changes hands, ties or not.
this.territory.set(key(city.coords.x, city.coords.y), newCiv);
this.training.delete(city.id);
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
// Aircraft caught on the captured airfield change allegiance with it.
this._captureCityAircraft(city, newCiv);
// The city's buildings now serve a different nation, so its modifier
// totals changed hands.
this._touchModifiers();
// Taking a city heartens the conqueror's people and demoralises the losers.
this._shiftOwnPopularity(newCiv, POPULARITY.cityCapture);
this._shiftOwnPopularity(oldCiv, -POPULARITY.cityLoss);
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
},
// Multi-source BFS over land from the captured city and every remaining city
// of the old owner. Each tile records the civ of the nearest city; a tile
// reached at the same distance from both sides is credited to the old owner.
_nearestCityOwner(captured, oldCiv, newCiv) {
const claim = new Map();
const visited = new Set();
let frontier = [];
const sources = [{ coords: captured.coords, civ: newCiv }];
for (const city of this.cities) {
if (city !== captured && city.civ === oldCiv) {
sources.push({ coords: city.coords, civ: oldCiv });
}
}
for (const source of sources) {
const k = key(source.coords.x, source.coords.y);
if (visited.has(k)) continue;
visited.add(k);
claim.set(k, source.civ);
frontier.push(source.coords);
}
while (frontier.length > 0) {
const nextFrontier = [];
const nextClaim = new Map();
for (const coords of frontier) {
const civ = claim.get(key(coords.x, coords.y));
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || !this._isLand(neighbour)) continue;
if (nextClaim.has(nk)) {
if (nextClaim.get(nk) === newCiv && civ === oldCiv) nextClaim.set(nk, oldCiv);
continue;
}
nextClaim.set(nk, civ);
nextFrontier.push(neighbour);
}
}
for (const [k, civ] of nextClaim) {
claim.set(k, civ);
visited.add(k);
}
frontier = nextFrontier;
}
return claim;
},
};