Files
Battle-for-Tismo/shared/game_state/sites.js
T
adrien c2f131da5c Turned tile improvements into construction sites with their own budgets
Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.

That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.

Bundles the in-progress chart sizing for the central bank panel.
2026-09-22 18:08:31 +02:00

386 lines
14 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). The owner may then raise the
// site's budget to the current prices, or 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 for its
// budget to be raised.
const PRICE_TOLERANCE = 1.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. `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 }) {
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 capacity = ECONOMY.productionCapacity(this.getPlayerGdp(civ));
const baseHours = kind === "upgrade"
? (capacity > 0 ? Math.max(1, buildCost / capacity) : 1)
: ECONOMY.trainingHours(this.getPlayerGdp(civ), 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,
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,
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);
}
} 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, so the
// owner is told (and may raise the budget or cancel).
_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.phase = "construction";
site.elapsedHours = 0;
this._emitChanged();
return;
}
const stalled = !boughtAnything;
if (site.stalled !== stalled) {
site.stalled = stalled;
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);
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;
}
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.
_sitePayer(site) {
const payer = { total: 0, overflow: 0 };
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;
},
// -------------------------------------------------- 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;
agent.cash = (agent.cash || 0) + site.budget;
this._gdpPerCapitaCache.clear();
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);
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._gdpPerCapitaCache.delete(k);
this._emitChanged();
},
// ------------------------------------------------- owner actions --
// Raises a stalled site's budget to the current material prices and unlocks
// it. The extra money comes from the public treasury.
requestIncreaseSiteBudget(civ, coords) {
const site = this.constructionSiteAt(coords);
if (!site || site.civ !== civ) return false;
const prices = this._lockedUnitPrices();
let wanted = 0;
for (const id of RESOURCE_IDS) {
const missing = Math.max(0, site.needed[id] - site.bought[id]);
wanted += missing * prices[id];
}
const add = Math.max(0, wanted - site.budget);
this._reserveSiteBudget(civ, add);
site.budget += add;
site.prices = prices;
site.stalled = false;
// Try again at once so the panel updates without waiting for the day.
if (site.phase === "materials") this._gatherSiteMaterials(site);
this._emitChanged();
return true;
},
// 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;
},
};