Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw. The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production. Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with. Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
513 lines
20 KiB
JavaScript
513 lines
20 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 whatever the market currently asks -- 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 only when no supplier of a needed material is within reach;
|
|
// there is no daily budget rise any more, the work simply buys at the current
|
|
// market price. 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";
|
|
|
|
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 the current market
|
|
// prices. A day that buys nothing while materials are still missing stalls the
|
|
// work because no supplier is within reach.
|
|
_gatherSiteMaterials(site) {
|
|
// Keep the site's reported prices pointed at the live market rather than a
|
|
// price frozen when the work was ordered.
|
|
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
|
|
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 prices what is
|
|
// still missing at the current market and moves the difference into the site's
|
|
// account, then tries again. There is no daily percentage rise any more: the
|
|
// site simply buys at whatever the market currently asks. When no supplier can
|
|
// reach the site at any price the top-up 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;
|
|
for (const id of RESOURCE_IDS) site.prices[id] = this._marketUnitCost(id);
|
|
const add = this._siteTopUp(site, site.prices);
|
|
if (!(add > 0)) return;
|
|
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. The site does not hold out for a
|
|
// locked price -- it buys at whatever the cheapest reachable supplier asks.
|
|
_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);
|
|
this._procureFromNeighbours(city, id, remaining, temp, nodes, {
|
|
payer,
|
|
allowSea,
|
|
maxUnitCost: Infinity,
|
|
});
|
|
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, entry.budgetCategory || "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;
|
|
},
|
|
};
|