Files
Battle-for-Tismo/shared/game_state/industry.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
2026-09-22 14:34:14 +02:00

375 lines
15 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 { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES } from "../data/resources.js";
import {
RESOURCE_BUILDING_UPGRADE,
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);
},
// ------------------------------------------------------------ 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);
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: !!agent.upgrading,
upgradeDaysLeft: agent.upgrading
? Math.max(0, Math.ceil((agent.upgrading.totalHours - agent.upgrading.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 every agent's upgrade, and once a day the demand read that may
// start a new one.
_tickIndustry() {
if (!this.buildingAgents || this.buildingAgents.size === 0) return;
for (const [k, agent] of this.buildingAgents) {
if (!agent.upgrading) continue;
agent.upgrading.elapsedHours += 1;
if (agent.upgrading.elapsedHours >= agent.upgrading.totalHours) {
this._completeBuildingUpgrade(k, agent);
}
}
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 (agent.upgrading) 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: buy the materials from its own stores and
// the reachable market with its own cash, then start the 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;
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;
}
if (!this._buyBuildingMaterials(k, coords, owner, materials)) return false;
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(owner));
const money = resourceBuildingUpgradeMoneyCost(proto, level);
const totalHours = capacity > 0
? Math.max(1, (money / capacity) * this.constructionSpeedMultiplier(owner))
: 1;
agent.upgrading = { elapsedHours: 0, totalHours, targetLevel: level + 1 };
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
_completeBuildingUpgrade(k, agent) {
agent.level = agent.upgrading ? agent.upgrading.targetLevel : agent.level + 1;
agent.upgrading = null;
// The extra output lifts the region's GDP, so the cached production is stale.
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._tileImprovementVersion += 1;
this._emitChanged();
},
// 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;
},
};