Files
Battle-for-Tismo/shared/game_state/sites.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

526 lines
21 KiB
JavaScript

// Construction sites: the economic nodes a tile improvement is raised through.
//
// Ordering a tile improvement no longer pays for it and raises it at once.
// Instead the state hands the site a forecast budget and the tile becomes an
// economic node that first gathers the materials it needs -- buying them from
// the reachable stores at the prices that stood when the work was decided -- and
// only then builds, drawing power from the grid as it goes. Leftover money stays
// with the improvement; a bill the site cannot cover is taken from the public
// treasury.
//
// A site stalls when a whole day passes without buying a needed material at the
// locked price (the market moved against it). Rather than waiting for a manual
// top-up, it raises its budgeted prices -- and the money behind them -- each
// day, 1% at first and doubling daily, until it can buy; only when no supplier
// is within reach at all does it keep a lasting warning. The owner may still
// cancel the work, losing everything already spent. Production buildings that
// finish become private agents and step through the same phases again when they
// upgrade.
import { key } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { tileImprovementById, transportImprovement } from "../data/improvements.js";
import {
tileImprovementResourceCost,
constructionResourceCost,
} from "../resources.js";
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
// A supplier whose delivered price has risen more than this much over the
// locked price is not bought from: the site waits for the market or raises its
// own budget.
const PRICE_TOLERANCE = 0.01;
// The first day a stalled site cannot buy, its budgeted prices and the matching
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
// it bids its way up to the market unaided.
const BUDGET_DAILY_RISE = 0.01;
const MATERIAL_IDS = ["steel", "hightech"];
const RESOURCE_IDS = ["steel", "hightech", "energy"];
function emptyResources() {
return { steel: 0, hightech: 0, energy: 0 };
}
export const siteMethods = {
// --------------------------------------------------------- lookup --
constructionSiteAt(coords) {
if (!this.constructionSites || !coords) return null;
return this.constructionSites.get(key(coords.x, coords.y)) || null;
},
// Every construction site a nation owns, newest last, as plain objects.
getConstructionSites(civ) {
const result = [];
if (!this.constructionSites) return result;
for (const site of this.constructionSites.values()) {
if (site.civ !== civ) continue;
result.push(site);
}
return result;
},
// How much money an improvement holds after it was raised.
improvementCashAt(coords) {
if (!this.improvementCash || !coords) return 0;
return this.improvementCash.get(key(coords.x, coords.y)) || 0;
},
// The delivered unit prices the site locked in when its work was decided.
_lockedUnitPrices() {
const prices = {};
for (const id of RESOURCE_IDS) prices[id] = this._marketUnitCost(id);
return prices;
},
// ------------------------------------------------- A* to a city --
// The cheapest allied city to haul to, found with A* over the land network so
// the route and the city agree. Roads and railways are cheaper to haul along,
// which is why a city reachable by rail may beat a nearer one over open land.
_siteCityPath(civ, coords) {
const cities = this.cities.filter((city) => city.civ === civ);
if (cities.length === 0) return { cityId: null, path: [] };
const start = { x: coords.x, y: coords.y };
const neighbours = (cell) => this._neighbours(cell);
const canEnter = (cell) => {
const tile = this.tiles[key(cell.x, cell.y)];
return !!tile && tile.terrainClass === "Land";
};
const stepCost = (_from, to) => {
const k = key(to.x, to.y);
if (this.railways.has(k)) return 0.25;
if (this.roads.has(k)) return 0.5;
return 1;
};
const heuristic = (from, to) => this.topology.tileDistance(from, to);
let bestCity = null;
let bestPath = null;
let bestCost = Infinity;
for (const city of cities) {
const path = this._pathfinder.findPath(
start, city.coords, neighbours, canEnter, stepCost, heuristic
);
if (path.length === 0) continue;
let cost = 0;
for (let i = 1; i < path.length; i++) cost += stepCost(path[i - 1], path[i]);
if (cost < bestCost) {
bestCost = cost;
bestCity = city;
bestPath = path;
}
}
if (!bestCity) return { cityId: null, path: [] };
return { cityId: bestCity.id, path: bestPath.map((cell) => [cell.x, cell.y]) };
},
// ---------------------------------------------------- creation --
// Opens a construction site on a tile. `money` is the forecast budget moved
// out of the coffers (or a private agent's cash) into the node; the caller has
// already reserved it. A `private` site belongs to a production agent: it keeps
// `money` only as the forecast the panel shows, and every purchase is charged
// to that agent's account (cash, then a central-bank loan) instead of the
// treasury. `proto` is the improvement being raised and `level` the production
// level a fresh build starts from (0) or the level an upgrade targets minus one.
_openConstructionSite({
civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0, isPrivate = false,
}) {
const k = key(coords.x, coords.y);
const materials = kind === "upgrade"
? this.constructionResourceCost(proto, level, buildCost)
: tileImprovementResourceCost(proto, buildCost);
const needed = emptyResources();
for (const id of RESOURCE_IDS) needed[id] = Math.max(0, materials[id] || 0);
const gdp = this.getPlayerGdp(civ);
const baseHours = ECONOMY.buildingHours(gdp, buildCost);
const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ));
const route = this._siteCityPath(civ, coords);
const site = {
coords: { x: coords.x, y: coords.y },
id: proto.id,
civ,
kind,
targetLevel,
private: !!isPrivate,
phase: "materials",
budget: Math.max(0, money || 0),
prices: this._lockedUnitPrices(),
needed,
bought: emptyResources(),
elapsedHours: 0,
totalHours,
cityId: route.cityId,
path: route.path,
stalled: false,
// Consecutive stalled days, driving the doubling budget rise.
budgetRaises: 0,
lastBuyDay: -1,
};
this.constructionSites.set(k, site);
this._emitChanged();
return site;
},
// Makes a site's first purchase at once, so an order feels immediate and the
// day's gathering is not repeated on the next tick.
_kickoffSite(site) {
site.lastBuyDay = Math.floor(this.totalHours / HOURS_PER_DAY);
if (site.phase === "materials") this._gatherSiteMaterials(site);
else this._buySiteEnergy(site);
},
// Moves `amount` from a nation's treasury to a site's own account, filing the
// expense under tile construction. Unlike a regular spend the money is not
// handed to the regions yet: the site still has to buy with it.
_reserveSiteBudget(civ, amount, category = "tilebuild") {
if (!(amount > 0)) return 0;
this.budgets.set(civ, this.getBudget(civ) - amount);
this._recordBudgetCash(civ, category, -amount);
return amount;
},
// The forecast a work is handed when it is decided: the build cost plus the
// market value of the steel and high-tech it will buy. Electricity is a grid
// draw billed as the build runs, so it is not part of the forecast.
_siteForecastBudget(civ, proto, buildCost, kind = "build", level = 0) {
const materials = kind === "upgrade"
? this.constructionResourceCost(proto, level, buildCost)
: tileImprovementResourceCost(proto, buildCost);
let total = buildCost;
for (const id of MATERIAL_IDS) total += (materials[id] || 0) * this.getResourcePrice(id);
return total;
},
// --------------------------------------------------- daily tick --
// One hour of every site: the materials phase buys once a day, the
// construction phase advances and buys the day's power.
_tickConstructionSites() {
if (!this.constructionSites || this.constructionSites.size === 0) return;
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
for (const site of Array.from(this.constructionSites.values())) {
const k = key(site.coords.x, site.coords.y);
if (!this.constructionSites.has(k)) continue;
if (site.phase === "materials") {
if (site.lastBuyDay !== day) {
site.lastBuyDay = day;
this._gatherSiteMaterials(site);
this._raiseSiteBudgetIfStalled(site);
}
} else {
site.elapsedHours += 1;
if (site.lastBuyDay !== day) {
site.lastBuyDay = day;
this._buySiteEnergy(site);
}
if (site.elapsedHours >= site.totalHours) this._completeConstructionSite(site);
}
}
},
// Tries to buy every material the site still needs at its locked prices. A day
// that buys nothing while materials are still missing stalls the work, and the
// budget below starts climbing to meet the market.
_gatherSiteMaterials(site) {
let boughtAnything = false;
for (const id of MATERIAL_IDS) {
const missing = site.needed[id] - site.bought[id];
if (!(missing > 1e-9)) continue;
const bought = this._siteBuy(site, id, missing);
if (bought > 0) {
site.bought[id] += bought;
boughtAnything = true;
}
}
const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9);
if (!stillMissing) {
site.stalled = false;
site.budgetRaises = 0;
site.phase = "construction";
site.elapsedHours = 0;
this._emitChanged();
return;
}
const stalled = !boughtAnything;
if (site.stalled !== stalled) {
site.stalled = stalled;
this._emitChanged();
}
if (!stalled) site.budgetRaises = 0;
},
// A day that bought nothing while a supplier is within reach raises the site's
// budgeted prices and moves the matching money out of the treasury, then tries
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
// 8%, ...), so a stubborn price is met within a few days. A supplier still
// above the raised cap simply stalls it once more; the count resets as soon as
// it buys. When no supplier can reach the site at any price the rise cannot
// help, so it is left alone. A private site raises its own agent's account
// instead of the treasury: the money is borrowed, not subsidised.
_raiseSiteBudgetIfStalled(site) {
if (!site.stalled || site.phase !== "materials") return;
if (this._siteUnreachableMaterials(site).length > 0) return;
const raises = site.budgetRaises || 0;
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
site.budgetRaises = raises + 1;
const add = this._siteTopUp(site, site.prices);
if (!site.private) this._reserveSiteBudget(site.civ, add);
site.budget += add;
this._gatherSiteMaterials(site);
this._emitChanged();
},
// Buys `amount` of one resource for the site: first from the allied city the
// route hauls to, then from the reachable stores, paying from the site's own
// budget first and the public treasury after. A supplier quoting above the
// locked price is skipped.
_siteBuy(site, id, amount) {
if (!(amount > 1e-9)) return 0;
if (!site.cityId) return 0;
const city = this._cityById.get(site.cityId);
if (!city) return 0;
const payer = this._sitePayer(site);
const paidBefore = payer.total;
let bought = 0;
// The hub city already holds some of the material; the state buys it from
// the region at the market value before reaching further afield.
const store = this.getCityResourceStock(city);
const take = Math.min(store[id] || 0, amount);
if (take > 0) {
store[id] -= take;
this._payRegionForMaterial(city, id, take, payer);
bought += take;
}
const remaining = amount - bought;
if (remaining > 1e-9) {
const nodes = this._resourceNodes();
const temp = emptyResources();
const allowSea = this._cityMarketAccess(city);
const cap = site.prices[id] * (1 + PRICE_TOLERANCE);
this._procureFromNeighbours(city, id, remaining, temp, nodes, {
payer,
allowSea,
maxUnitCost: cap,
});
bought += temp[id] || 0;
}
// File the purchase under tile construction with its quantity, so the
// budget panel can show how many units were bought and at what price. A
// private work is not a public expense, so it is left out of the budget.
if (bought > 0 && !site.private) {
const proto = tileImprovementById(site.id) || transportImprovement(site.id);
this._recordBudgetCategoryResource(
site.civ, "tilebuild", id, payer.total - paidBefore, proto ? proto.name : site.id, bought
);
}
return bought;
},
// The day's power a site under construction draws, bought the same way.
_buySiteEnergy(site) {
const missing = site.needed.energy - site.bought.energy;
if (!(missing > 1e-9)) return 0;
const perDay = Math.min(
missing,
(site.needed.energy / Math.max(site.totalHours, 1)) * HOURS_PER_DAY
);
const bought = this._siteBuy(site, "energy", perDay);
if (bought > 0) site.bought.energy += bought;
return bought;
},
// A payer that empties the site's own account first, then draws on the
// nation's treasury, so an overrun is public money. A private site instead
// draws on its production agent: that agent's cash first, then a central-bank
// loan, so a producer is never subsidised by the state.
_sitePayer(site) {
const payer = { total: 0, overflow: 0 };
if (site.private) {
const k = key(site.coords.x, site.coords.y);
payer.charge = (cost) => {
if (!(cost > 0)) return;
this._chargeBuildingAccount(k, cost);
payer.total += cost;
};
return payer;
}
payer.charge = (cost) => {
if (!(cost > 0)) return;
const fromBudget = Math.min(site.budget, cost);
site.budget -= fromBudget;
const rest = cost - fromBudget;
if (rest > 0) {
this.budgets.set(site.civ, this.getBudget(site.civ) - rest);
this._recordBudgetCash(site.civ, "tilebuild", -rest);
payer.overflow += rest;
}
payer.total += cost;
};
return payer;
},
// ------------------------------------------------ city building orders --
// One day of a queued city building's material phase. Like a construction
// site it buys whatever of its steel and high-tech bill it still lacks, at the
// prices locked in when the order was placed: the nation's own stores first,
// then the reachable suppliers. It only flips to construction once the whole
// bill is gathered; a day that buys nothing leaves it stalled.
_gatherBuildEntry(city, entry) {
const payer = this._entryPayer(city, entry);
const remaining = { steel: 0, hightech: 0 };
for (const id of MATERIAL_IDS) {
remaining[id] = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0));
}
// The nation's own cities are bought from first, nearest to the building.
const drawn = this._drawFromNearest(city.civ, city.coords, remaining, payer);
for (const id of MATERIAL_IDS) {
if (drawn[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + drawn[id];
}
for (const id of MATERIAL_IDS) {
const missing = (entry.needed[id] || 0) - (entry.bought[id] || 0);
if (!(missing > 1e-9)) continue;
const temp = emptyResources();
this._procureFromNeighbours(city, id, missing, temp, this._resourceNodes(), {
payer,
allowSea: this._cityMarketAccess(city),
});
if (temp[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + temp[id];
}
const stillMissing = MATERIAL_IDS.some(
(id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9
);
if (!stillMissing) entry.phase = "construction";
entry.stalled = stillMissing;
return !stillMissing;
},
// A payer for a queued building: its reserved material budget first, then the
// public treasury, so an overrun is public money.
_entryPayer(city, entry) {
const payer = { total: 0, overflow: 0 };
payer.charge = (cost) => {
if (!(cost > 0)) return;
const fromBudget = Math.min(entry.budget || 0, cost);
entry.budget = (entry.budget || 0) - fromBudget;
const rest = cost - fromBudget;
if (rest > 0) {
this.budgets.set(city.civ, this.getBudget(city.civ) - rest);
this._recordBudgetCash(city.civ, "construction", -rest);
payer.overflow += rest;
}
payer.total += cost;
};
return payer;
},
// Hands a finished order's unspent material budget back to the treasury. The
// reserved money was never paid to the regions, so it is not clawed back.
_releaseEntryBudget(civ, entry) {
if (!(entry.budget > 0)) return;
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
this._recordBudgetCash(civ, "construction", entry.budget);
entry.budget = 0;
},
// -------------------------------------------------- completion --
_completeConstructionSite(site) {
const k = key(site.coords.x, site.coords.y);
if (this.constructionSites.get(k) !== site) return;
const civ = site.civ;
const proto = tileImprovementById(site.id) || transportImprovement(site.id);
if (!proto || this.civAt(site.coords) !== civ) {
this.constructionSites.delete(k);
return;
}
if (site.kind === "upgrade") {
const agent = this._ensureBuildingAgent(k);
agent.level = site.targetLevel;
agent.upgrading = null;
// 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._clearTileGdpCache();
this._touchModifiers();
this._tileImprovementVersion += 1;
} else if (transportImprovement(site.id)) {
this.roads.delete(k);
this.railways.delete(k);
if (site.id === "railway") this.railways.add(k);
else this.roads.add(k);
this._improvementVersion += 1;
if (site.budget > 0) {
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
}
} else {
this.tileImprovements.set(k, site.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[site.id] || 1000);
if (this._isProductionAgent(proto)) {
const agent = this._ensureBuildingAgent(k);
// A public work hands its unspent budget to the new private owner as
// working capital; a private one already borrowed for itself.
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
} else if (site.budget > 0) {
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
}
this._tileImprovementVersion += 1;
this._visibilityDirty = true;
}
this.constructionSites.delete(k);
this._dropTileGdpCache(k);
this._emitChanged();
},
// ------------------------------------------------- owner actions --
// The materials a site still needs that no reachable source can provide: the
// hub city's own store is empty and no supplier sits within the trade radius.
// A non-empty result means no budget, however large, can unstall the work, so
// the panel keeps a lasting warning instead.
_siteUnreachableMaterials(site) {
const missing = MATERIAL_IDS.filter(
(id) => site.needed[id] - site.bought[id] > 1e-9
);
if (missing.length === 0) return [];
const city = site.cityId != null ? this._cityById.get(site.cityId) : null;
if (!city) return missing.slice();
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
const store = this.getCityResourceStock(city);
const unreachable = [];
for (const id of missing) {
if ((store[id] || 0) > 0) continue;
if (this._nearbySuppliers(city, id, nodes, allowSea).length > 0) continue;
unreachable.push(id);
}
return unreachable;
},
// The extra a site must be granted to buy its remaining materials at the given
// unit prices (its own budgeted prices by default): the shortfall between what
// those materials cost and the money the site still holds.
_siteTopUp(site, prices = null) {
const p = prices || site.prices;
let wanted = 0;
for (const id of RESOURCE_IDS) {
const missing = Math.max(0, site.needed[id] - site.bought[id]);
wanted += missing * p[id];
}
return Math.max(0, wanted - site.budget);
},
// Cancels a site. Nothing is refunded: the budget already handed to it and
// whatever materials it gathered are lost.
requestCancelSite(civ, coords) {
const site = this.constructionSiteAt(coords);
if (!site || site.civ !== civ) return false;
this.constructionSites.delete(key(site.coords.x, site.coords.y));
this._emitChanged();
return true;
},
};