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

387 lines
16 KiB
JavaScript

// Production buildings as independent private-sector agents. Every resource
// building -- a steel mill, a mine, a chip foundry, a power plant -- is not
// merely a region's asset: it keeps its own cash from what it sells, watches
// how its own demand moves, and reinvests that cash in upgrading itself. A
// converter makes only what it can sell and the grid pays its plants for the
// power it delivers (see `_settleGridPower`), so no agent is forced to run at
// a permanent loss.
//
// The daily tick rolls each agent's sales into a short window, then lets an
// agent whose recent sales beat its older ones -- or that is selling out -- buy
// the steel and high-tech for its next level and start the work. An upgrade
// raises throughput exponentially but the extra output costs power linearly
// (see RESOURCE_BUILDING_UPGRADE in shared/data/resources.js), and an agent may
// only have one upgrade under way at a time.
import { key, parseKey } from "../hex.js";
import { RESOURCE_RULES } from "../data/resources.js";
import {
RESOURCE_BUILDING_UPGRADE,
RESOURCE_EFFICIENCY_EFFECT,
resourceBuildingOutputAt,
resourceBuildingInputAt,
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
isResourceTileBuilding,
} from "../data.js";
import { HOURS_PER_DAY } from "./constants.js";
function emptyBuildingAgent() {
return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null };
}
export const industryMethods = {
// ------------------------------------------------------- agent state --
// Every resource building trades: a material producer sells its good, a
// power plant sells the power its grid delivers.
_isProductionAgent(proto) {
return isResourceTileBuilding(proto);
},
_ensureBuildingAgent(k) {
if (!this.buildingAgents) this.buildingAgents = new Map();
let agent = this.buildingAgents.get(k);
if (!agent) {
agent = emptyBuildingAgent();
this.buildingAgents.set(k, agent);
}
return agent;
},
// Drops the agent of a tile whose building is gone.
_removeBuildingAgent(k) {
if (this.buildingAgents) this.buildingAgents.delete(k);
},
buildingAgentAt(coords) {
if (!this.buildingAgents) return null;
return this.buildingAgents.get(key(coords.x, coords.y)) || null;
},
resourceBuildingLevel(k) {
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
return agent ? agent.level : 0;
},
// The output per day of the building on `k`, compounded by its level.
resourceBuildingOutputAt(k, proto) {
return resourceBuildingOutputAt(proto, this.resourceBuildingLevel(k));
},
// The operating input of the building on `k` at its level. `field` names the
// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`).
resourceBuildingInputAt(k, proto, field = "energyPerDay") {
return resourceBuildingInputAt(proto, this.resourceBuildingLevel(k), field);
},
// The nation's output-per-input bonus for one commodity, from its efficiency
// research. Energy drawn to make that commodity is divided by `1 + bonus`.
// Read from the technology modifiers alone, so seeding never primes the
// cached building-modifier totals with a half-built world.
resourceEfficiency(civ, resource) {
if (civ < 0) return 0;
const stat = RESOURCE_EFFICIENCY_EFFECT[resource];
if (!stat) return 0;
let cached = this._resourceEfficiencyCache ? this._resourceEfficiencyCache.get(civ) : null;
if (!cached || cached.version !== this._modifiersVersion) {
if (!this._resourceEfficiencyCache) this._resourceEfficiencyCache = new Map();
cached = { version: this._modifiersVersion, values: this.getCivTechnologyModifiers(civ) };
this._resourceEfficiencyCache.set(civ, cached);
}
return Math.max(0, cached.values[stat] || 0);
},
// ------------------------------------------------------------ money --
// A buyer's payment reaches the producing building, into its own cash, in the
// currency of the nation it belongs to whatever currency it was paid in.
_creditBuildingCash(k, code, amount) {
if (!(amount > 0)) return;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return;
const agent = this._ensureBuildingAgent(k);
const value = amount * this.currencyValueOf(code) / this.currencyValue(owner);
agent.cash = (agent.cash || 0) + value;
},
// Records that a production agent sold `qty` of its output during the day,
// feeding the demand trend that decides whether it grows.
_recordBuildingSale(k, qty) {
if (!(qty > 0)) return;
const agent = this._ensureBuildingAgent(k);
agent.salesToday = (agent.salesToday || 0) + qty;
},
// A payer that draws on one production building's own cash.
_buildingPayer(k) {
const agent = this._ensureBuildingAgent(k);
const payer = { total: 0 };
payer.charge = (cost) => {
if (!(cost > 0)) return;
// An agent never mints money: a bill it cannot cover leaves it overdrawn,
// to be paid off from the next sales.
agent.cash = (agent.cash || 0) - cost;
payer.total += cost;
};
return payer;
},
// Charges one production building for something the grid delivered to it,
// from its own cash. Like `_buildingPayer`, it may go overdrawn rather than
// fail, so the money a supplier receives is real.
_chargeBuildingCash(k, code, cost) {
if (!(cost > 0)) return;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return;
const agent = this._ensureBuildingAgent(k);
const value = cost * this.currencyValueOf(code) / this.currencyValue(owner);
agent.cash = (agent.cash || 0) - value;
},
// Pays a grid's plants for `kwh` of power worth `money`, split by how much
// each contributed, and credits each with the sale its demand trend reads.
_payGridProducers(component, money, kwh) {
const producers = (component && component.producers) || [];
let total = 0;
for (const producer of producers) total += producer.output || 0;
if (!(total > 0)) {
if (money > 0 && component.civ >= 0) this._creditRegions(component.civ, money);
return;
}
const code = this.currencyOf(component.civ).code;
for (const producer of producers) {
const share = (producer.output || 0) / total;
if (money > 0) this._creditBuildingCash(producer.k, code, money * share);
if (kwh > 0) this._recordBuildingSale(producer.k, kwh * share);
}
},
// Everything the nation's production buildings hold, in the nation's currency.
getPrivateBuildingCash(civ) {
if (!this.buildingAgents) return 0;
let total = 0;
for (const [k, agent] of this.buildingAgents) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
total += agent.cash || 0;
}
return total;
},
// A nation's production agents as plain objects, for the Economy tab's
// separate private-sector row and the tile panel.
getProductionAgents(civ) {
const result = [];
if (!this.buildingAgents) return result;
for (const [k, agent] of this.buildingAgents) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (!this._isProductionAgent(proto)) continue;
const coords = parseKey(k);
const site = this.constructionSites ? this.constructionSites.get(k) : null;
const upgradingSite = site && site.kind === "upgrade" ? site : null;
const job = upgradingSite || agent.upgrading;
result.push({
coords: [coords.x, coords.y],
name: proto.name,
resource: proto.resource,
level: agent.level,
cash: agent.cash || 0,
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
upgrading: !!job,
upgradeDaysLeft: job
? Math.max(0, Math.ceil((job.totalHours - job.elapsedHours) / HOURS_PER_DAY))
: 0,
});
}
return result;
},
// The steel and high-tech one upgrade level needs, scaled off its money cost
// like any other construction. Only an advanced producer needs the high-tech.
_upgradeMaterialCost(proto, level) {
const money = resourceBuildingUpgradeMoneyCost(proto, level);
return {
steel: money * RESOURCE_RULES.steelPerBudgetEuro,
hightech: proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
};
},
// How much of its capacity a converter should run at. It runs flat out while
// its store is thin -- so it always keeps up with demand -- and only eases off
// once it is sitting on more stock than it has been selling, making roughly
// what it sells so it never burns power on goods it cannot move. A brand-new
// building has no history and runs flat out.
_converterProductionScale(k, proto, agent) {
const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0);
if (!(capacity > 0)) return 1;
const sales = (agent && agent.sales) || [];
let expected = capacity;
if (sales.length > 0) {
const half = Math.max(1, Math.floor(sales.length / 2));
const recent = sales.slice(-half);
expected = recent.reduce((sum, value) => sum + value, 0) / recent.length;
}
const target = Math.max(capacity, expected * 2);
const store = this.resourceStock ? this.resourceStock.get(k) : null;
const stock = (store || {})[proto.resource] || 0;
if (stock <= target) return 1;
// Overstocked: make only what has been selling, but never switch off.
return Math.max(0.1, Math.min(1, expected / capacity));
},
// What the next level's materials would cost at today's delivered market
// price, in the agent's national currency. The delivered cost folds in a
// nominal two-tile haul, so the quoted bill is not beaten by a distant
// supplier's freight.
_upgradeMaterialValue(civ, materials) {
const value = materials.steel * this._marketUnitCost("steel") +
materials.hightech * this._marketUnitCost("hightech");
return value / this.currencyValue(civ);
},
// ------------------------------------------------------- daily tick --
// One hour of industry, and once a day the demand read that may start a new
// upgrade. The upgrade itself is a construction site: it gathers materials,
// builds and becomes the next level through the shared site tick.
_tickIndustry() {
if (!this.buildingAgents || this.buildingAgents.size === 0) return;
if (this.totalHours % HOURS_PER_DAY === 0) this._tickBuildingDecisions();
},
_tickBuildingDecisions() {
// Roll today's sales into the trend window before reading it.
for (const agent of this.buildingAgents.values()) {
agent.sales.push(agent.salesToday || 0);
if (agent.sales.length > RESOURCE_BUILDING_UPGRADE.salesWindow) agent.sales.shift();
agent.salesToday = 0;
}
for (const [k, agent] of this.buildingAgents) {
if (this.constructionSites && this.constructionSites.has(k)) continue;
if (agent.level >= RESOURCE_BUILDING_UPGRADE.maxLevel) continue;
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (!this._isProductionAgent(proto)) continue;
if (!this._buildingDemandGrowing(agent, this.resourceBuildingOutputAt(k, proto))) {
continue;
}
this._startBuildingUpgrade(k, proto, agent);
}
},
// Whether the agent should grow: its recent sales have to beat its older ones,
// or it has to be selling out (demand at least matching what it can make).
_buildingDemandGrowing(agent, capacity = 0) {
const sales = agent.sales || [];
if (sales.length < 2) return false;
const half = Math.floor(sales.length / 2);
const older = sales.slice(0, half);
const recent = sales.slice(half);
const average = (list) => list.reduce((sum, value) => sum + value, 0) / list.length;
const before = average(older);
const now = average(recent);
if (now > before * (1 + RESOURCE_BUILDING_UPGRADE.demandGrowth)) return true;
return capacity > 0 &&
now >= capacity * RESOURCE_BUILDING_UPGRADE.sellThrough &&
now >= before;
},
// Commit an agent to its next level. It hands its cash to a construction
// site, which gathers the materials, builds, and raises the level on
// completion like any other work. Only one upgrade may run at a time.
_startBuildingUpgrade(k, proto, agent) {
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return false;
if (this.constructionSites && this.constructionSites.has(k)) return false;
const level = agent.level;
const materials = this._upgradeMaterialCost(proto, level);
const quoted = this._upgradeMaterialValue(owner, materials) *
RESOURCE_BUILDING_UPGRADE.cashMargin;
if ((agent.cash || 0) < quoted) return false;
const coords = parseKey(k);
if (this._buildingMaterialAvailability(owner, coords, "steel") < materials.steel) return false;
if (materials.hightech > 0 &&
this._buildingMaterialAvailability(owner, coords, "hightech") < materials.hightech) {
return false;
}
const money = resourceBuildingUpgradeMoneyCost(proto, level);
const budget = Math.max(0, agent.cash || 0);
agent.cash = 0;
const site = this._openConstructionSite({
civ: owner,
coords,
proto,
kind: "upgrade",
targetLevel: level + 1,
money: budget,
buildCost: money,
level,
});
this._kickoffSite(site);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
// The amount of `id` an agent could lay hands on for an upgrade: its own
// store for free, the nation's city stores, and the nearby producers' surplus.
_buildingMaterialAvailability(civ, coords, id) {
const k = key(coords.x, coords.y);
let available = (this.resourceStock.get(k) || {})[id] || 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
available += this.getCityResourceStock(city)[id] || 0;
}
const city = this._nearestCity(civ, coords);
if (!city) return available;
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) {
if (supplier.kind !== "building") continue;
if (key(supplier.coords.x, supplier.coords.y) === k) continue;
available += this._nodeSurplus(supplier, id);
}
return available;
},
// Spends the agent's cash on the upgrade's materials: its own store first
// (already its own), then the nation's regions, then the reachable market.
_buyBuildingMaterials(k, coords, civ, materials) {
const own = this._ensureResourceStock(k);
const ownSteel = Math.min(materials.steel, own.steel || 0);
const ownTech = Math.min(materials.hightech, own.hightech || 0);
if (ownSteel > 0) own.steel -= ownSteel;
if (ownTech > 0) own.hightech -= ownTech;
let needSteel = materials.steel - ownSteel;
let needTech = materials.hightech - ownTech;
const payer = this._buildingPayer(k);
if (needSteel > 0) {
needSteel -= this._drawFromNearest(civ, coords, { steel: needSteel, hightech: 0 }, payer).steel;
}
if (needTech > 0) {
needTech -= this._drawFromNearest(civ, coords, { steel: 0, hightech: needTech }, payer).hightech;
}
if (needSteel > 0 || needTech > 0) {
const city = this._nearestCity(civ, coords);
if (city) {
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
const haul = { steel: 0, hightech: 0 };
if (needSteel > 0) {
needSteel -= this._procureFromNeighbours(
city, "steel", needSteel, haul, nodes, { payer, allowSea }
);
}
if (needTech > 0) {
needTech -= this._procureFromNeighbours(
city, "hightech", needTech, haul, nodes, { payer, allowSea }
);
}
}
}
return needSteel <= 1e-6 && needTech <= 1e-6;
},
};