Files
Battle-for-Tismo/shared/game_state/sites.js
T
adrien aa726ff431 Made queued buildings gather their materials and rise ten times faster
City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.

Buildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes.
2026-09-22 21:43:31 +02:00

495 lines
19 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. `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 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,
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.
_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);
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);
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;
},
// ------------------------------------------------ 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;
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 --
// 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;
},
};