Files
Battle-for-Tismo/shared/game_state/industry.js
T
adrien 80429918c6 Made producers pay their own way and settled the world to steady prices
- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.
- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.
- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.
- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).
- The shipped month report carries its label again, so the budget header and pager name the month.
2026-09-23 21:45:28 +02:00

431 lines
18 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, debt: 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 payer = { total: 0 };
payer.charge = (cost) => {
if (!(cost > 0)) return;
this._chargeBuildingAccount(k, cost);
payer.total += cost;
};
return payer;
},
// Charges one production building, spending its cash and borrowing whatever
// is missing from the national central bank at the bank's rate. Like a region,
// an agent never goes into the red: it takes on a loan instead, which the
// daily tick compounds and repays from later sales.
_chargeBuildingAccount(k, amount) {
if (!(amount > 0)) return;
const agent = this._ensureBuildingAgent(k);
const cash = agent.cash || 0;
if (cash >= amount) {
agent.cash = cash - amount;
return;
}
agent.cash = 0;
agent.debt = (agent.debt || 0) + (amount - cash);
},
// Charges one production building for something the grid delivered to it,
// from its own cash, borrowing from the central bank where the cash falls
// short, 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 value = cost * this.currencyValueOf(code) / this.currencyValue(owner);
this._chargeBuildingAccount(k, 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;
},
// What the nation's production buildings owe the central bank in total.
getPrivateBuildingDebt(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.debt || 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,
debt: agent.debt || 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. Its output follows what
// it has actually been selling across the whole watch window, not its nameplate
// capacity: a building ramps up only as demand is sustained and eases off again
// when demand fades, 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;
// A converter that cannot cover its power bill at today's prices does not run
// at all; idling beats making a loss.
if (!this._converterProfitable(k, proto, agent)) return 0;
const sales = (agent && agent.sales) || [];
let expected = capacity;
if (sales.length > 0) {
expected = sales.reduce((sum, value) => sum + value, 0) / sales.length;
}
const target = 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));
},
// Whether a converter can cover its power bill at today's market prices. Making
// a material that sells for less than the energy it burns is a loss the private
// agent will not take, so it idles for the day instead. Efficiency research
// cuts the power each unit needs, so it only ever helps.
_converterProfitable(k, proto, agent) {
if (!proto || proto.resource === "energy") return true;
const level = agent ? agent.level : 0;
const output = resourceBuildingOutputAt(proto, level);
if (!(output > 0)) return true;
const owner = this.tileImprovementOwner.get(k);
const efficiency = 1 + this.resourceEfficiency(owner, proto.resource);
const energy = resourceBuildingInputAt(proto, level, "energyPerDay") / efficiency;
if (!(energy > 0)) return true;
const revenue = output * this.getResourcePrice(proto.resource);
const bill = energy * this.getResourcePrice("energy");
return revenue >= bill;
},
// 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);
// A private work: the agent keeps its cash and the site draws on it (then a
// central-bank loan) as it buys, so an upgrade is never a public expense.
const site = this._openConstructionSite({
civ: owner,
coords,
proto,
kind: "upgrade",
targetLevel: level + 1,
money,
buildCost: money,
level,
isPrivate: true,
});
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;
},
};