Files
Battle-for-Tismo/shared/game_state/resources.js
T
adrien e7ee5dbf0e Added the resource economy, its works and their icons
- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.
- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.
- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
2026-09-22 05:27:11 +02:00

1693 lines
69 KiB
JavaScript

// Resources: the five material groups nations produce, store, trade and
// consume. Storage lives on the map -- a city tile or a resource-building tile
// holds its own stock -- while energy is a flow: power plants feed the grid of
// their connected region, and a shortfall is bought and delivered for money.
//
// The daily tick is the heart of it: produce, satisfy electric demand, consume
// what the people need, buy the rest from neighbours and finally from the
// global market, then let shortages bite and prices drift.
import { key, parseKey } from "../hex.js";
import {
RESOURCE_IDS,
STORABLE_RESOURCE_IDS,
RESOURCE_RULES,
RESOURCE_MARKET_BASE,
RESOURCE_TRADE_RADIUS,
tileImprovementById,
isResourceTileBuilding,
MONEY,
} from "../data.js";
import {
cityEnergyPerDay,
foodNeedPerDay,
tileFoodOutput,
steelNeedPerDay,
luxuryNeedPerDay,
foodMonthlyTarget,
producerEnergyPerDay,
deliveryEnergyForResource,
nextMarketPrice,
upkeepResources,
protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js";
import { buildingBuildCost } from "../rules.js";
import { Random } from "../rng.js";
import { TILE_IMPROVEMENT_HP } from "./constants.js";
// A city keeps at least this many days of a material in stock before it will
// sell any of it on; the rest is surplus a neighbour may buy.
const SUPPLIER_RESERVE_DAYS = 2;
// The producers a fresh nation may be seeded with. Energy uses the large
// fossil plants so a nation needs few works to light its industry; the luxury
// pick is randomised between the two mines. A producer locked behind research
// (fusion, and the chip foundry, which no opening need calls for) never
// appears.
const SEED_PRODUCERS = {
steel: ["steel_mill"],
luxury: ["diamond_mine", "gold_mine"],
hightech: ["chip_foundry"],
energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
};
// How many top-up passes the start-of-game seeding makes before it gives up on
// the tiles that are left. Each pass places everything a resource is short by,
// so three or four passes settle a nation.
const SEED_MAX_PASSES = 10;
// A little headroom, so the opening economy is not balanced on a knife edge as
// prices and population drift in the first days. Power gets the larger cushion
// because it scales with GDP, which the market moves.
const SEED_MATERIAL_MARGIN = 1.05;
const SEED_ENERGY_MARGIN = 1.15;
function emptyStock() {
return { steel: 0, food: 0, luxury: 0, hightech: 0 };
}
export const resourceMethods = {
// -------------------------------------------------------- setup/state --
_initResources() {
this.resourceStock = new Map();
this.resourcePrices = new Map();
this.resourceMarketStats = new Map();
for (const id of RESOURCE_IDS) {
this.resourcePrices.set(id, RESOURCE_MARKET_BASE[id]);
this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
}
this.resourceLinks = [];
this._resourceShortages = new Map();
this._resourceVersion = 0;
// What each nation imported and exported over the last simulated day, by
// resource, from cross-nation deliveries only.
this.resourceTrade = new Map();
// What each nation spent on resources during the current day, split into
// electric imports, bought goods, transport, construction and upkeep.
this.resourceSpend = new Map();
// Repair state. City building levels knocked down by bombardment wait here
// to be rebuilt once their materials are paid; a damaged tile work carries a
// steel debt that must clear before it heals or its GDP recovers.
this.cityRepairQueue = new Map();
this.tileRepairDebt = new Map();
for (const city of this.cities) this._seedCityResources(city);
},
// Records money spent on resources. `resource` may name the one commodity the
// bill was for, or be a `{ id: money }` map when several were bought at once,
// so the budget panel can show a per-resource breakdown.
_recordResourceSpend(civ, kind, amount, resource = null) {
if (!(amount > 0)) return;
const record = this._resourceRecord(civ);
record[kind] = (record[kind] || 0) + amount;
if (typeof resource === "string") {
record.buy[resource] = (record.buy[resource] || 0) + amount;
this._recordBudgetResource(civ, resource, amount, 0);
} else if (resource) {
for (const [id, value] of Object.entries(resource)) {
if (value > 0) {
record.buy[id] = (record.buy[id] || 0) + value;
this._recordBudgetResource(civ, id, value, 0);
}
}
}
},
// Records money earned selling a resource to another country.
_recordResourceEarn(civ, resource, amount) {
if (!(amount > 0)) return;
const record = this._resourceRecord(civ);
record.sell[resource] = (record.sell[resource] || 0) + amount;
this._recordBudgetResource(civ, resource, 0, amount);
},
_resourceRecord(civ) {
let record = this.resourceSpend.get(civ);
if (!record) {
record = {
energy: 0, goods: 0, transport: 0, construction: 0, upkeep: 0, combat: 0,
buy: {}, sell: {},
};
this.resourceSpend.set(civ, record);
}
if (!record.buy) record.buy = {};
if (!record.sell) record.sell = {};
return record;
},
// Every city opens with most of a month of food, a little steel and luxury,
// and some high-tech, so an early game with no producer yet can still build.
_seedCityResources(city) {
const k = key(city.coords.x, city.coords.y);
const stock = emptyStock();
stock.food = foodMonthlyTarget(city.population) * 0.75;
stock.steel = steelNeedPerDay(city.population) * 45;
stock.luxury = luxuryNeedPerDay(city.population) * 45;
stock.hightech = 500;
this.resourceStock.set(k, stock);
},
_ensureResourceStock(k) {
let stock = this.resourceStock.get(k);
if (!stock) {
stock = emptyStock();
this.resourceStock.set(k, stock);
}
return stock;
},
// ------------------------------------------------------ starting industry --
// Lays the resource works a nation opens the game with, so a fresh economy
// already makes what its people and its industry need. Called during world
// generation, after the world and its balanced economy exist but before the
// budgets are founded, so the opening treasury reflects the works. It draws
// from the world seed, so a game replayed from the same seed lays out the
// same map. A nation short of land (a one-city island) gets as many works as
// fit and trades for the rest.
_seedResourceBuildings() {
const rng = new Random((this.seed ^ 0x27d4eb2f) >>> 0);
const components = this._resourceLandComponents();
// A work only counts when its land component holds one of the nation's
// cities: otherwise its grid has no demand and its output no store to feed.
const componentCivs = new Map();
for (const city of this.cities) {
const component = components.get(key(city.coords.x, city.coords.y));
if (component === undefined) continue;
if (!componentCivs.has(component)) componentCivs.set(component, new Set());
componentCivs.get(component).add(city.civ);
}
// The food the land grows is fixed for the whole pass, so charge its energy
// once. Ports lift it, so the region multiplier is folded in here.
const foodEnergy = this._foodEnergyByComponent(components);
const isNetwork = (k) => this.roads.has(k) || this.railways.has(k);
const touchesNetwork = (coords) => {
for (const neighbour of this._neighbours(coords)) {
if (isNetwork(key(neighbour.x, neighbour.y))) return true;
}
return false;
};
const claims = [];
for (let civ = 0; civ < this.civilisations.length; civ++) {
// Tiles are ranked so the works land where the transport network can
// reach them: on the network first, then beside it (a one-tile spur
// connects them), and only then off it.
const tiers = [[], [], []];
const tiersByComponent = new Map();
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
if (this.cityAt(coords) || this.tileImprovements.has(k)) continue;
const component = components.get(k);
if (component === undefined || !componentCivs.get(component)?.has(civ)) continue;
const tier = isNetwork(k) ? 0 : touchesNetwork(coords) ? 1 : 2;
tiers[tier].push(coords);
if (!tiersByComponent.has(component)) tiersByComponent.set(component, [[], [], []]);
tiersByComponent.get(component)[tier].push(coords);
}
for (const list of tiers) rng.shuffle(list);
for (const lists of tiersByComponent.values()) for (const list of lists) rng.shuffle(list);
const spots = tiers[0].concat(tiers[1], tiers[2]);
const byComponent = new Map();
for (const [component, lists] of tiersByComponent) {
byComponent.set(component, lists[0].concat(lists[1], lists[2]));
}
claims.push({ civ, spots, byComponent });
}
for (const claim of claims) {
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
}
this._gdpPerCapitaCache.clear();
},
// The greedy heart of the seeding: each pass covers every resource the nation
// is still short of, converters first so the power pass sees their draw. It
// stops as soon as a pass changes nothing. A work off the transport network
// gets a one-tile road spur where it can, so its output can actually reach a
// city.
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
const used = new Set();
let cursor = 0;
const takeSpot = (component) => {
if (component !== undefined && byComponent.has(component)) {
const list = byComponent.get(component);
while (list.length > 0) {
const coords = list.shift();
const k = key(coords.x, coords.y);
if (this.tileImprovements.has(k) || used.has(k)) continue;
used.add(k);
return coords;
}
}
while (cursor < spots.length) {
const coords = spots[cursor];
cursor += 1;
const k = key(coords.x, coords.y);
if (this.tileImprovements.has(k) || used.has(k)) continue;
used.add(k);
return coords;
}
return null;
};
const place = (proto, component, count) => {
let laid = 0;
for (let i = 0; i < count; i++) {
const coords = takeSpot(component);
if (!coords) break;
const k = key(coords.x, coords.y);
this.tileImprovements.set(k, proto.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
// A work beside the network gets a spur; one already on it needs none.
if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
this.roads.add(k);
}
laid += 1;
}
return laid;
};
for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
let changed = false;
const deficits = this._resourceSeedDeficits(civ);
for (const id of ["steel", "luxury", "hightech"]) {
const target = deficits[id] * SEED_MATERIAL_MARGIN;
if (target <= 0) continue;
const proto = this._pickSeedProducer(rng, id);
const count = Math.max(1, Math.ceil(target / proto.outputPerDay));
if (place(proto, undefined, count) > 0) changed = true;
}
// Power is settled per connected grid, after the converters just placed
// have added their draw, and the plants themselves add fuel and upkeep.
// The GDP figure the ledger reads must reflect those converters, so the
// per-tile cache is dropped before it is consulted.
this._gdpPerCapitaCache.clear();
const energy = this._resourceSeedEnergy(civ, components, foodEnergy);
for (const entry of energy) {
const target = entry.deficit * SEED_ENERGY_MARGIN;
if (target <= 0) continue;
const proto = this._pickSeedProducer(rng, "energy");
const count = Math.max(1, Math.ceil(target / this._netEnergyOutput(proto)));
if (place(proto, entry.component, count) > 0) changed = true;
}
if (!changed) break;
const settled = this._resourceSeedDeficits(civ);
const energyLeft = this._resourceSeedEnergy(civ, components, foodEnergy);
if (settled.steel <= 1 && settled.luxury <= 1 && settled.hightech <= 1 &&
energyLeft.every((entry) => entry.deficit <= 1e-6)) break;
}
},
// What a nation's lands still make less than they need, in tonnes or carats a
// day: the people's appetite plus the works' own wear against what the
// resource buildings produce. Energy is settled separately, per grid.
_resourceSeedDeficits(civ) {
const population = this.getPlayerPopulation(civ);
const upkeep = this.getCivUpkeepResources(civ);
const made = this._civResourceProduction(civ);
return {
steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
luxury: luxuryNeedPerDay(population) - made.luxury,
hightech: (upkeep.hightech || 0) - made.hightech,
};
},
// The daily output of a nation's material buildings, by resource. Energy is
// not a stock, so it is left out and handled by the grid ledger.
_civResourceProduction(civ) {
const made = { steel: 0, luxury: 0, hightech: 0 };
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
made[proto.resource] = (made[proto.resource] || 0) + proto.outputPerDay;
}
return made;
},
// Each of a nation's connected grids that is still short of power: the cities
// and industry it draws, plus the energy the land's food takes, against what
// the plants on it make. Returns one entry per short grid.
_resourceSeedEnergy(civ, components, foodEnergy) {
const ledger = this._buildEnergyLedger(components);
const result = [];
for (const [component, entry] of ledger) {
if (entry.civ !== civ) continue;
const food = foodEnergy.get(component) || 0;
const deficit = entry.cityEnergy + entry.converterEnergy + food - entry.supply;
if (deficit > 0) result.push({ component, deficit });
}
return result;
},
// The energy the land's food costs each connected grid, in kWh a day, ports
// included. Fixed for the whole seeding pass.
_foodEnergyByComponent(components) {
const result = new Map();
const regionMultipliers = new Map();
for (const k in this.tiles) {
const coords = parseKey(k);
if (!this._isLand(coords)) continue;
const population = this.tilePopulation.get(k) || 0;
if (population <= 0) continue;
const component = components.get(k);
if (component === undefined) continue;
const city = this.regionCityAt(coords);
let multiplier = 1;
if (city) {
multiplier = regionMultipliers.get(city.id);
if (multiplier === undefined) {
multiplier = this._regionFoodMultiplier(city);
regionMultipliers.set(city.id, multiplier);
}
}
const produced = tileFoodOutput(this.tiles[k]) * multiplier;
result.set(component, (result.get(component) || 0) + producerEnergyPerDay("food", produced));
}
return result;
},
// What a plant actually adds to its grid: its output less the fuel it burns
// and the power its own upkeep draws.
_netEnergyOutput(proto) {
return (proto.outputPerDay || 0) -
(proto.fuelEnergyPerDay || 0) -
protoUpkeep(proto, proto.buildCost || 0).energy;
},
// A random producer of one resource, chosen from the seeded catalogue.
_pickSeedProducer(rng, resource) {
const ids = SEED_PRODUCERS[resource];
return tileImprovementById(ids[rng.range(0, ids.length - 1)]);
},
getResourcePrice(id) {
const price = this.resourcePrices ? this.resourcePrices.get(id) : undefined;
return price !== undefined ? price : (RESOURCE_MARKET_BASE[id] || 1);
},
setResourcePrice(id, value) {
this.resourcePrices.set(id, value);
},
// The storage a tile has, or null when it is neither a city nor a resource
// building. Used both by the simulation and the delivery graph.
_resourceNodeAt(coords) {
const city = this.cityAt(coords);
if (city) return { kind: "city", civ: city.civ, city };
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return null;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) return null;
return { kind: "building", civ: this.tileImprovementOwner.get(k), proto };
},
getCityResourceStock(city) {
return this._ensureResourceStock(key(city.coords.x, city.coords.y));
},
// What one city's people need in a day, and how that compares with its store.
getCityResourceInfo(city) {
const economy = this.getCityEconomy(city);
const population = economy.population;
const stock = this.getCityResourceStock(city);
const needs = {
food: foodNeedPerDay(population),
steel: steelNeedPerDay(population),
luxury: luxuryNeedPerDay(population),
};
const shortage = this._resourceShortages.get(city.id) || {};
return {
cityId: city.id,
population: Math.round(population),
energyPerDay: cityEnergyPerDay(economy.gdp),
stock: { ...stock },
needs,
foodTarget: foodMonthlyTarget(population),
shortage: { ...shortage },
};
},
// Every resource figure one nation's cities hold, for the summary panel: the
// summed stock, the summed daily need and the summed production. Energy has
// no store, so it travels as a daily demand and the plants' daily supply.
getCivResourceSummary(civ) {
const stock = emptyStock();
const need = emptyStock();
let energyNeed = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
const store = this.getCityResourceStock(city);
for (const id of STORABLE_RESOURCE_IDS) stock[id] += store[id] || 0;
const economy = this.getCityEconomy(city);
const population = economy.population;
need.food += foodNeedPerDay(population);
need.steel += steelNeedPerDay(population);
need.luxury += luxuryNeedPerDay(population);
energyNeed += cityEnergyPerDay(economy.gdp);
}
const production = emptyStock();
let energySupply = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue;
if (proto.resource === "energy") energySupply += proto.outputPerDay;
else production[proto.resource] += proto.outputPerDay;
}
// The land's harvest is part of what the nation produces.
const foodMultipliers = new Map();
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
if ((this.tilePopulation.get(k) || 0) <= 0) continue;
const city = this.regionCityAt(coords);
let multiplier = 1;
if (city) {
multiplier = foodMultipliers.get(city.id);
if (multiplier === undefined) {
multiplier = this._regionFoodMultiplier(city);
foodMultipliers.set(city.id, multiplier);
}
}
production.food += tileFoodOutput(this.tiles[k]) * multiplier;
}
const upkeep = this.getCivUpkeepResources(civ);
// What the nation actually consumes in a day: the people's needs plus the
// materials its buildings, units, works and transport wear out. Energy is a
// flow on both sides, so its row combines the grid demand and upkeep.
const consumed = {
food: need.food + (upkeep.food || 0),
steel: need.steel + (upkeep.steel || 0),
luxury: need.luxury + (upkeep.luxury || 0),
hightech: upkeep.hightech || 0,
energy: energyNeed + (upkeep.energy || 0),
};
return {
stock,
need,
production,
energy: { need: energyNeed, supply: energySupply },
// What the nation's buildings, units, works and transport wear out daily.
upkeep,
consumed,
// Yesterday's cross-nation deliveries: what the nation imported and sold.
trade: this._civResourceTrade(civ),
prices: Object.fromEntries(RESOURCE_IDS.map((id) => [id, this.getResourcePrice(id)])),
};
},
// One nation's imports and exports of every resource over the last simulated
// day, as plain per-resource objects. Cross-nation deliveries only; the
// global market is not attributed to a partner.
_civResourceTrade(civ) {
const record = this.resourceTrade ? this.resourceTrade.get(civ) : null;
const imports = {};
const exports = {};
for (const id of RESOURCE_IDS) {
imports[id] = record ? record.imports[id] || 0 : 0;
exports[id] = record ? record.exports[id] || 0 : 0;
}
return { imports, exports };
},
// Records a cross-nation delivery: the buyer gained `amount` of `id` and the
// seller shipped it. Both nations' daily trade ledgers are updated.
_recordResourceTrade(importer, exporter, id, amount) {
if (!(amount > 0)) return;
if (!this.resourceTrade) this.resourceTrade = new Map();
const record = (civ, kind) => {
if (civ < 0) return;
let entry = this.resourceTrade.get(civ);
if (!entry) {
entry = { imports: {}, exports: {} };
this.resourceTrade.set(civ, entry);
}
entry[kind][id] = (entry[kind][id] || 0) + amount;
};
record(importer, "imports");
if (exporter !== importer) record(exporter, "exports");
},
// --------------------------------------------------- construction steel --
// The steel and high-tech a construction spends. Every item needs steel
// proportional to its money cost; an advanced building (science, air, radar,
// chips) also needs high-tech, more with every level.
constructionResourceCost(proto, level = 0, buildCost = null) {
return sharedConstructionResourceCost(proto, level, buildCost);
},
// Pays a construction's material bill. The stock of the nation's cities is
// drawn down first, then the global market covers the rest at its current
// price. Energy is a flow rather than a store, so it is bought from the
// market outright. Returns false only when the nation cannot afford the
// market bill, in which case nothing is charged and the drawn stock is put
// back.
_payConstructionResources(civ, coords, resources, category = "construction") {
const steel = resources.steel || 0;
const hightech = resources.hightech || 0;
const energy = resources.energy || 0;
if (steel <= 0 && hightech <= 0 && energy <= 0) return true;
const fromStock = this._drawFromNearest(civ, coords, { steel, hightech });
const shortfall = {
steel: steel - fromStock.steel,
hightech: hightech - fromStock.hightech,
};
const materialsShort = shortfall.steel > 0 || shortfall.hightech > 0;
// No working port means no foreign market: the stores must cover the bill.
// Energy is the exception -- a nation always powers its own works.
if (materialsShort && !this._civMarketAccess(civ)) {
this._refundToNearest(civ, coords, fromStock);
return false;
}
const buyEnergy = this._civMarketAccess(civ) ? energy : 0;
const steelCost = this._marketCost(shortfall.steel, "steel");
const techCost = this._marketCost(shortfall.hightech, "hightech");
const energyCost = this._marketCost(buyEnergy, "energy");
const cost = steelCost + techCost + energyCost;
if (!this._spendBudget(civ, cost, "construction")) {
this._refundToNearest(civ, coords, fromStock);
return false;
}
// The whole bill, paid or drawn from stores, is the construction's material
// cost for the month's budget report.
this._recordBudgetEconomic(
civ,
category,
steel * this.getResourcePrice("steel") +
hightech * this.getResourcePrice("hightech") +
energy * this.getResourcePrice("energy")
);
if (steelCost > 0) this._recordBudgetCategoryResource(civ, category, "steel", steelCost);
if (techCost > 0) this._recordBudgetCategoryResource(civ, category, "hightech", techCost);
if (energyCost > 0) this._recordBudgetCategoryResource(civ, category, "energy", energyCost);
if (cost > 0) {
this._recordResourceSpend(civ, "construction", cost, {
steel: steelCost,
hightech: techCost,
energy: energyCost,
});
}
const steelMarket = this.resourceMarketStats.get("steel");
if (steelMarket) steelMarket.demand += shortfall.steel;
const techMarket = this.resourceMarketStats.get("hightech");
if (techMarket) techMarket.demand += shortfall.hightech;
if (buyEnergy > 0) {
const energyMarket = this.resourceMarketStats.get("energy");
if (energyMarket) energyMarket.demand += buyEnergy;
}
return true;
},
_marketCost(amount, id) {
if (amount <= 0) return 0;
return amount * this.getResourcePrice(id);
},
// Pulls each material from the nearest of the nation's cities to `coords`,
// capped by what that city holds. Returns how much of each was taken.
_drawFromNearest(civ, coords, wanted) {
const taken = { steel: 0, hightech: 0 };
const remaining = { steel: wanted.steel || 0, hightech: wanted.hightech || 0 };
const cities = this.cities
.filter((city) => city.civ === civ)
.sort((a, b) =>
this.topology.tileDistance(a.coords, coords) -
this.topology.tileDistance(b.coords, coords));
for (const city of cities) {
const store = this.getCityResourceStock(city);
for (const id of ["steel", "hightech"]) {
if (remaining[id] <= 0) continue;
const available = store[id] || 0;
const take = Math.min(available, remaining[id]);
if (take <= 0) continue;
store[id] = available - take;
remaining[id] -= take;
taken[id] += take;
}
if (remaining.steel <= 0 && remaining.hightech <= 0) break;
}
return taken;
},
_refundToNearest(civ, coords, resources) {
let city = null;
for (const other of this.cities) {
if (other.civ !== civ) continue;
if (!city || this.topology.tileDistance(other.coords, coords) <
this.topology.tileDistance(city.coords, coords)) city = other;
}
if (!city) return;
const store = this.getCityResourceStock(city);
store.steel += resources.steel || 0;
store.hightech += resources.hightech || 0;
},
// ---------------------------------------------------------- upkeep --
// What one city building level wears out in a day. A level's upkeep is priced
// off the cost of raising that level, so it grows with the level.
buildingUpkeepResources(proto, level) {
return protoUpkeep(proto, buildingBuildCost(proto, level - 1));
},
unitUpkeepResources(proto) {
return protoUpkeep(proto, proto.cost);
},
// Everything a nation's buildings, units, tile works and transport network
// wear out in a day, split by the budget category that owns it, so the monthly
// report can attribute the cash and the market value. Energy is the exception
// units do not pay: it is fed to the grid through the city/improvement a thing
// stands in, so it is settled by `_buildEnergyLedger` instead.
getCivUpkeepBreakdown(civ) {
const categories = {
buildings: { steel: 0, energy: 0, hightech: 0 },
units: { steel: 0, energy: 0, hightech: 0 },
works: { steel: 0, energy: 0, hightech: 0 },
transport: { steel: 0, energy: 0, hightech: 0 },
};
const add = (category, costs) => {
category.steel += costs.steel || 0;
category.energy += costs.energy || 0;
category.hightech += costs.hightech || 0;
};
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (proto) add(categories.buildings, this.buildingUpkeepResources(proto, level));
}
}
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto) continue;
// A unit in battle burns through its stores faster.
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
const multiplier = this._statusUpkeepMultiplier(statuses);
const costs = this.unitUpkeepResources(proto);
add(categories.units, {
steel: costs.steel * multiplier,
hightech: costs.hightech * multiplier,
});
}
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
add(categories.works, protoUpkeep(proto, proto.buildCost || 0));
if (proto.fuelEnergyPerDay) categories.works.energy += proto.fuelEnergyPerDay;
}
const counts = this._transportTiles(civ);
add(categories.transport, {
steel: counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway,
});
return categories;
},
// The same daily wear as one materials demand, for the summary panels and the
// market-value forecast.
getCivUpkeepResources(civ) {
const categories = this.getCivUpkeepBreakdown(civ);
const total = { steel: 0, energy: 0, hightech: 0, food: 0 };
for (const costs of Object.values(categories)) {
total.steel += costs.steel;
total.energy += costs.energy;
total.hightech += costs.hightech;
}
return total;
},
// An infantry unit's soldiers eat like civilians, doubled in combat and
// tripled while wounded and resting in one of their own cities. Used by the
// supply tick to decide how fast a unit's carried food runs down.
_unitFoodRate(unit, proto, inBattle = false) {
const population = proto && proto.infantry ? (proto.population || RESOURCE_RULES.unitPopulation) : 0;
if (population <= 0) return 0;
const city = this.cityAt(unit.coords);
const resting = !!(city && city.civ === unit.civ && unit.hp < unit.maxHp);
const multiplier = resting
? RESOURCE_RULES.infantryRestingFoodMultiplier
: inBattle
? RESOURCE_RULES.infantryCombatFoodMultiplier
: 1;
return foodNeedPerDay(population) * multiplier;
},
// Pulls a material from the nation's cities, nearest first not required, and
// returns what is still missing.
_drawFromCities(civ, id, amount) {
let remaining = Math.max(0, amount);
for (const city of this.cities) {
if (remaining <= 0) break;
if (city.civ !== civ) continue;
const store = this.getCityResourceStock(city);
const take = Math.min(store[id] || 0, remaining);
if (take <= 0) continue;
store[id] = (store[id] || 0) - take;
remaining -= take;
}
return remaining;
},
// Settles one nation's daily upkeep materials: draw the stores, buy the rest
// from the market with the treasury, and return the fraction of the bill the
// nation could actually pay. The unpaid fraction becomes unrest.
_settleUpkeep(civ, categories) {
const ids = ["buildings", "units", "works", "transport"];
const steelUnit = this._marketUnitCost("steel");
const techUnit = this._marketUnitCost("hightech");
let totalSteel = 0;
let totalTech = 0;
let drawnSteel = 0;
let drawnTech = 0;
const shortfalls = {};
// Draw each category's demand from the shared city stores. The totals do
// not depend on the order, only which category gets the stock first does.
for (const id of ids) {
const demand = categories[id] || {};
const steel = demand.steel || 0;
const hightech = demand.hightech || 0;
const steelShort = steel > 0 ? this._drawFromCities(civ, "steel", steel) : 0;
const techShort = hightech > 0 ? this._drawFromCities(civ, "hightech", hightech) : 0;
shortfalls[id] = { steel: steelShort, hightech: techShort };
totalSteel += steel;
totalTech += hightech;
drawnSteel += steel - steelShort;
drawnTech += hightech - techShort;
}
const steelShortTotal = totalSteel - drawnSteel;
const techShortTotal = totalTech - drawnTech;
// What the stores covered, against what still has to be bought.
const storeCost = drawnSteel * steelUnit + drawnTech * techUnit;
const shortfall = steelShortTotal * steelUnit + techShortTotal * techUnit;
const full = storeCost + shortfall;
// The market value of a category's wear is an economic cost whether the
// stores covered it or the treasury had to buy it.
for (const id of ids) {
const demand = categories[id] || {};
this._recordBudgetEconomic(
civ,
id,
(demand.steel || 0) * steelUnit + (demand.hightech || 0) * techUnit
);
}
if (storeCost > 0 && shortfall <= 0) return 1;
if (full <= 0) return 1;
// No port, no foreign market: only the stores count, and the rest is unmet.
if (!this._civMarketAccess(civ)) return storeCost / full;
// The government can always pay, borrowing the rest from its central bank,
// so the whole bill is settled and the fraction is one.
const fraction = 1;
if (shortfall > 0) {
this._spendBudget(civ, shortfall * fraction);
this._recordResourceSpend(civ, "upkeep", shortfall * fraction, {
steel: steelShortTotal * steelUnit * fraction,
hightech: techShortTotal * techUnit * fraction,
});
for (const id of ids) {
const short = shortfalls[id];
const steelCash = short.steel * steelUnit * fraction;
const techCash = short.hightech * techUnit * fraction;
const cash = steelCash + techCash;
if (cash > 0) this._recordBudgetCash(civ, id, -cash);
if (steelCash > 0) this._recordBudgetCategoryResource(civ, id, "steel", steelCash);
if (techCash > 0) this._recordBudgetCategoryResource(civ, id, "hightech", techCash);
}
const steelMarket = this.resourceMarketStats.get("steel");
if (steelMarket) steelMarket.demand += steelShortTotal * fraction;
const techMarket = this.resourceMarketStats.get("hightech");
if (techMarket) techMarket.demand += techShortTotal * fraction;
}
return (storeCost + shortfall * fraction) / full;
},
// ------------------------------------------------------ combat use --
// What one use of a weapon spends. Guns and aircraft are cheap parked and
// costly to fire: the state draws its stores and buys the rest. The payment
// is atomic -- if the treasury cannot cover the market part, the stores are
// put back and nothing is spent. Returns whether the shot could be paid for.
payCombatResources(civ, kind, count = 1) {
const consumption = RESOURCE_RULES.combatConsumption[kind];
if (!consumption) return true;
const wanted = {
steel: (consumption.steel || 0) * count,
hightech: (consumption.hightech || 0) * count,
};
if (wanted.steel <= 0 && wanted.hightech <= 0) return true;
const drawn = this._drawFromNearest(civ, this._anyCityCoords(civ), wanted);
const missing = {
steel: wanted.steel - drawn.steel,
hightech: wanted.hightech - drawn.hightech,
};
// No port, no foreign market: the stores must cover the whole shot.
if ((missing.steel > 0 || missing.hightech > 0) && !this._civMarketAccess(civ)) {
this._refundToNearest(civ, this._anyCityCoords(civ), drawn);
return false;
}
const cost = missing.steel * this._marketUnitCost("steel") +
missing.hightech * this._marketUnitCost("hightech");
// The government can always pay; an overdraft is a central-bank debt.
this._spendBudget(civ, cost, "military");
if (cost > 0) {
this._recordBudgetCategoryResource(
civ, "military", "steel", missing.steel * this._marketUnitCost("steel")
);
this._recordBudgetCategoryResource(
civ, "military", "hightech", missing.hightech * this._marketUnitCost("hightech")
);
this._recordResourceSpend(civ, "combat", cost, {
steel: missing.steel * this._marketUnitCost("steel"),
hightech: missing.hightech * this._marketUnitCost("hightech"),
});
const steelMarket = this.resourceMarketStats.get("steel");
if (steelMarket) steelMarket.demand += missing.steel;
const techMarket = this.resourceMarketStats.get("hightech");
if (techMarket) techMarket.demand += missing.hightech;
}
return true;
},
_anyCityCoords(civ) {
for (const city of this.cities) if (city.civ === civ) return city.coords;
return { x: 0, y: 0 };
},
// ------------------------------------------------------- market access --
// Which landmass a tile sits on. Continents never change, so they are built
// once and cached.
_continentOf(coords) {
if (!this._continentByTile) this._buildContinents();
return this._continentByTile.get(key(coords.x, coords.y));
},
_buildContinents() {
this._continentByTile = new Map();
let id = 0;
for (const k in this.tiles) {
if (this.tiles[k].terrainClass !== "Land") continue;
if (this._continentByTile.has(k)) continue;
const queue = [parseKey(k)];
this._continentByTile.set(k, id);
while (queue.length > 0) {
const coords = queue.pop();
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (this._continentByTile.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || tile.terrainClass !== "Land") continue;
this._continentByTile.set(nk, id);
queue.push(neighbour);
}
}
id += 1;
}
},
// Whether a city can reach another continent's markets: its own continent
// holds a working friendly port. No working port, no foreign resources.
_cityMarketAccess(city) {
const continent = this._continentOf(city.coords);
for (const port of this.cities) {
if (port.civ !== city.civ) continue;
if (this._continentOf(port.coords) !== continent) continue;
if (this._hasSeaSupply(port.coords, port.civ)) return true;
}
return false;
},
_civMarketAccess(civ) {
for (const city of this.cities) {
if (city.civ !== civ) continue;
if (this._cityMarketAccess(city)) return true;
}
return false;
},
// ------------------------------------------------------------ daily tick --
_tickResources() {
if (!this.resourceStock) return;
this._resourceVersion += 1;
this.resourceLinks = [];
this.resourceSpend = new Map();
this.resourceTrade = new Map();
this._resourceShortages = new Map();
const market = new Map();
for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) });
this.resourceMarketStats = market;
const components = this._resourceLandComponents();
const ledger = this._buildEnergyLedger(components);
// Food is grown by every worked tile, from the land and how fertile it is
// rather than from the headcount, and stored by the region's city. Ports
// bring in extra, so a coastal region's multiplier is looked up once per
// region and reused for every tile it covers. The energy it takes is
// charged to the tile's grid component.
const pendingFood = [];
const foodMultipliers = new Map();
for (const k in this.tiles) {
const coords = parseKey(k);
if (!this._isLand(coords)) continue;
const population = this.tilePopulation.get(k) || 0;
if (population <= 0) continue;
const city = this.regionCityAt(coords);
if (!city) continue;
let foodMultiplier = foodMultipliers.get(city.id);
if (foodMultiplier === undefined) {
foodMultiplier = this._regionFoodMultiplier(city);
foodMultipliers.set(city.id, foodMultiplier);
}
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
if (produced <= 0) continue;
pendingFood.push({
cityId: city.id,
coords: city.coords,
component: components.get(k),
amount: produced,
});
const component = ledger.get(components.get(k));
if (component) component.foodEnergy += producerEnergyPerDay("food", produced);
}
// Settle every connected grid: what the plants make against what the cities
// and industry draw. The shortfall is bought from the market and capped by
// what the nation can pay.
for (const component of ledger.values()) {
if (component.civ < 0) continue;
const demand = component.cityEnergy + component.converterEnergy + component.foodEnergy;
const deficit = Math.max(0, demand - component.supply);
component.demand = demand;
component.available = component.supply + this._buyGridEnergy(component.civ, deficit);
const industrial = component.converterEnergy + component.foodEnergy;
component.industrialScale = industrial > 0
? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial))
: 1;
const cityServed = Math.min(
component.cityEnergy,
Math.max(0, component.available - industrial * component.industrialScale)
);
component.cityShortage = component.cityEnergy > 0
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
: 0;
market.get("energy").supply += component.supply;
market.get("energy").demand += demand;
}
// Food lands in the region's store, scaled by the power the grid could give.
for (const entry of pendingFood) {
const component = ledger.get(entry.component);
const scale = component ? component.industrialScale : 1;
const produced = entry.amount * scale;
if (produced <= 0) continue;
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
market.get("food").supply += produced;
}
// Converters turn the power they were allocated into their material, held
// at the building's own tile.
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
const component = ledger.get(components.get(k));
const scale = component ? component.industrialScale : 1;
const produced = proto.outputPerDay * scale;
if (produced <= 0) continue;
this._ensureResourceStock(k)[proto.resource] += produced;
market.get(proto.resource).supply += produced;
}
// The people eat, wear and spend. Shortfalls are matched against nearby
// stores, then the market.
this._consumeCityResources(components, market);
// Every nation's buildings, units and works then wear out: the state draws
// its stores and buys the rest, and an unpaid bill becomes unrest.
for (let civ = 0; civ < this.civilisations.length; civ++) {
const categories = this.getCivUpkeepBreakdown(civ);
const paid = this._settleUpkeep(civ, categories);
this._civilianUpkeepShortage(civ, 1 - paid);
}
// Once a day the world's imbalance sets the next price.
for (const id of RESOURCE_IDS) {
const stats = market.get(id);
const price = nextMarketPrice(
RESOURCE_MARKET_BASE[id],
this.getResourcePrice(id),
stats.supply,
stats.demand
);
this.setResourcePrice(id, price);
stats.price = price;
}
this._applyResourceShortages(ledger);
this._pruneResourceStock();
},
// Every land tile assigned to the connected component of owned land it sits
// on. Two cities of a nation share a grid only when their land is contiguous.
_resourceLandComponents() {
const component = new Map();
let next = 0;
for (const [startKey, owner] of this.territory) {
if (component.has(startKey)) continue;
const queue = [parseKey(startKey)];
component.set(startKey, next);
while (queue.length > 0) {
const coords = queue.pop();
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (component.has(nk)) continue;
if (this.territory.get(nk) !== owner) continue;
component.set(nk, next);
queue.push(neighbour);
}
}
next += 1;
}
return component;
},
// A per-component energy ledger, keyed by the component id from
// `_resourceLandComponents`. Each entry carries the owner and the day's draw and
// supply, filled in as the world is walked.
_buildEnergyLedger(components) {
const ledger = new Map();
for (const [k, id] of components) {
if (ledger.has(id)) continue;
ledger.set(id, {
civ: this.territory.get(k),
supply: 0,
cityEnergy: 0,
converterEnergy: 0,
foodEnergy: 0,
demand: 0,
available: 0,
industrialScale: 1,
cityShortage: 0,
});
}
for (const city of this.cities) {
const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
if (!component) continue;
component.cityEnergy += cityEnergyPerDay(this.getCityEconomy(city).gdp);
// The city's buildings run on the grid too, so their operating power is
// charged to the same component.
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (proto) component.cityEnergy += this.buildingUpkeepResources(proto, level).energy;
}
}
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto) continue;
const component = ledger.get(components.get(k));
if (!component) continue;
if (isResourceTileBuilding(proto) && proto.resource === "energy") {
component.supply += proto.outputPerDay;
} else if (isResourceTileBuilding(proto)) {
component.converterEnergy += proto.energyPerDay || 0;
}
component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy;
if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
}
return ledger;
},
// Buys the grid's electric shortfall on the market, capped by what the nation
// can pay. Returns the kWh actually bought.
_buyGridEnergy(civ, deficit) {
if (deficit <= 0) return 0;
// Without a working port there is no foreign market to buy power from.
if (!this._civMarketAccess(civ)) return 0;
const price = this.getResourcePrice("energy");
this._spendBudget(civ, deficit * price, "energy");
this._recordBudgetCategoryResource(civ, "energy", "energy", deficit * price);
this._recordResourceSpend(civ, "energy", deficit * price, "energy");
return deficit;
},
_consumeCityResources(components, market) {
const nodes = this._resourceNodes();
// How much of each supplier's surplus earlier buyers have already claimed
// today, so a contested surplus goes to whoever bids highest.
this._nodeClaims = new Map();
for (const city of this.cities) {
const population = this.getCityEconomy(city).population;
const needs = {
food: foodNeedPerDay(population),
steel: steelNeedPerDay(population),
luxury: luxuryNeedPerDay(population),
};
const store = this.getCityResourceStock(city);
const shortage = {};
const foodTarget = foodMonthlyTarget(population);
// An encircled city is cut off from neighbours and the foreign market:
// it lives off its own reserves until the ring is broken. A portless city
// can still trade over land on its own continent, but not across water.
const cutOff = this._isEncircled(city.coords, city.civ);
const marketAccess = !cutOff && this._cityMarketAccess(city);
for (const id of ["food", "steel", "luxury"]) {
const need = needs[id];
market.get(id).demand += need;
const own = Math.min(store[id] || 0, need);
store[id] = (store[id] || 0) - own;
const dailyShortfall = need - own;
// Food is stocked ahead: the city tops its store up to 130% of the
// coming month's needs, not just today's. Steel and luxury are bought
// as they are eaten.
const wanted = id === "food"
? Math.max(dailyShortfall, foodTarget - store[id])
: dailyShortfall;
let bought = 0;
if (wanted > 0 && !cutOff) {
bought = this._procureFromNeighbours(
city, id, wanted, store, nodes, this._marketUnitCost(id), marketAccess
);
if (bought < wanted && marketAccess) {
bought += this._buyMarketMaterial(city, id, wanted - bought, store);
}
}
// What was bought against today's need is eaten today; only the food
// buffer left over stays in the store.
const consumed = Math.min(bought, dailyShortfall);
if (consumed > 0) store[id] -= consumed;
if (dailyShortfall > bought + 0.0001 && need > 0) {
shortage[id] = (dailyShortfall - bought) / need;
}
}
if (Object.keys(shortage).length > 0) this._resourceShortages.set(city.id, shortage);
}
},
// A city buys a material from the cheapest source it can reach: a nearby
// store whose delivered price beats the market, or the market itself. The
// city pays from its own private cash, and only the region that owns the
// surplus is paid; a foreign seller insists on its national currency, which
// the buyer acquires from that nation's central bank. Returns the amount
// bought, and records each successful leg on the delivery graph. `marketUnit`
// is the market's delivered price per unit; suppliers at or above it are
// ignored. A surplus several buyers chase is bid up: each earlier claim raises
// the price the next buyer pays.
_procureFromNeighbours(city, id, amount, cityStore, nodes, marketUnit = Infinity, allowSea = true) {
let remaining = amount;
const price = this.getResourcePrice(id);
const energyPrice = this.getResourcePrice("energy");
if (!this._nodeClaims) this._nodeClaims = new Map();
const buyerCode = this.currencyOf(city.civ).code;
const quotes = [];
for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) {
const surplus = this._nodeSurplus(supplier, id);
if (surplus <= 0) continue;
const claimed = this._nodeClaims.get(key(supplier.coords.x, supplier.coords.y)) || 0;
const available = Math.max(0, surplus - claimed);
if (available <= 0) continue;
const foreign = supplier.civ >= 0 && supplier.civ !== city.civ;
// A contested surplus is bid up, but never past the cap.
const premium = Math.min(
MONEY.maxBidMultiplier,
1 + MONEY.bidPremium * (claimed / Math.max(1e-9, surplus))
);
// Every seller is paid for the goods in its own currency -- a neighbour
// in the shared national currency, a foreigner after a trip to its central
// bank. A contested surplus carries the bid premium.
const goods = price * premium;
const freight = (supplier.routeEnergyPerTonne || 0) * energyPrice;
const unitCost = goods + freight;
if (unitCost >= marketUnit) continue;
quotes.push({ supplier, available, foreign, unitCost, goods, freight });
}
quotes.sort((a, b) => a.unitCost - b.unitCost);
for (const quote of quotes) {
if (remaining <= 0) break;
const { supplier, foreign, goods, freight } = quote;
const bought = Math.min(quote.available, remaining);
if (!(bought > 0)) continue;
const goodsValue = goods * bought;
const freightValue = freight * bought;
const vBuyer = this.currencyValue(city.civ);
// The region pays from its cash and borrows the rest from its central
// bank, so a purchase is never refused for want of money.
if (foreign) {
this._chargeRegionCash(city, buyerCode, freightValue / vBuyer);
this._payWorld(city.civ, freightValue / vBuyer, buyerCode);
const acquired = this._regionAcquireCurrency(city, supplier.civ, goodsValue);
if (!acquired) continue;
this._paySupplier(supplier, acquired.code, acquired.amount);
this._recordResourceTrade(city.civ, supplier.civ, id, bought);
this._recordCurrencyDemand(supplier.civ, goodsValue);
this._recordCurrencyDemand(city.civ, -goodsValue);
} else {
this._chargeRegionCash(city, buyerCode, (goodsValue + freightValue) / vBuyer);
this._paySupplier(supplier, buyerCode, goodsValue / vBuyer);
this._payWorld(city.civ, freightValue / vBuyer, buyerCode);
}
this._nodeClaims.set(
key(supplier.coords.x, supplier.coords.y),
(this._nodeClaims.get(key(supplier.coords.x, supplier.coords.y)) || 0) + bought
);
supplier.stock[id] -= bought;
cityStore[id] += bought;
remaining -= bought;
this.resourceLinks.push({
from: [supplier.coords.x, supplier.coords.y],
to: [city.coords.x, city.coords.y],
resource: id,
amount: bought,
});
}
return amount - remaining;
},
// Pays the region that owns a surplus, into its private-sector cash.
_paySupplier(node, code, amount) {
if (!(amount > 0)) return;
const city = node.city || this._regionCityNear(node.civ, node.coords);
if (city) this._creditRegionCash(city, code, amount);
},
// The market's delivered price per unit: the good plus the energy a nominal
// two-tile road delivery burns.
_marketUnitCost(id) {
const energyPerUnit = deliveryEnergyForResource(id, 1, 2, "road", 1);
return this.getResourcePrice(id) + energyPerUnit * this.getResourcePrice("energy");
},
// The market always has the goods; a city pays the price and a nominal
// two-tile delivery from its own private cash, in its national currency.
// Returns how much it could afford.
_buyMarketMaterial(city, id, amount, cityStore) {
if (amount <= 0) return 0;
const civ = city.civ;
// The foreign market is only reachable through a working port.
if (!this._civMarketAccess(civ)) return 0;
const code = this.currencyOf(civ).code;
const perUnit = this._marketUnitCost(id) / this.currencyValue(civ);
if (perUnit <= 0) return 0;
if (!this.regionCash.get(city.id)) return 0;
// The region pays from its cash and borrows the rest from its central bank,
// so the whole order goes through. The money is paid to the world market's
// producers, so it does not drain out of the world.
const payment = amount * perUnit;
this._chargeRegionCash(city, code, payment);
this._payWorld(civ, payment, code);
cityStore[id] += amount;
return amount;
},
_resourceNodes() {
const nodes = [];
for (const [k, stock] of this.resourceStock) {
const coords = parseKey(k);
const city = this.cityAt(coords);
if (city) {
nodes.push({ coords, civ: city.civ, city, stock, kind: "city" });
continue;
}
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (isResourceTileBuilding(proto)) {
nodes.push({
coords,
civ: this.tileImprovementOwner.get(k),
stock,
kind: "building",
proto,
});
}
}
return nodes;
},
// The storage nodes within reach of a city that hold a surplus of `id`, in
// order of transport energy. A breadth-first search over land and sea, capped
// at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated
// kWh to move one tonne from the city to that node.
_nearbySuppliers(city, id, nodes, allowSea = true) {
const results = [];
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y);
const visited = new Set([origin]);
// Every delivery runs over the transport network: railways and roads on
// land, shipping lanes across water (which need a working port).
const queue = [{ coords: city.coords, tiles: 0, energy: 0 }];
while (queue.length > 0) {
const current = queue.shift();
const here = byKey.get(key(current.coords.x, current.coords.y));
if (here && key(here.coords.x, here.coords.y) !== origin) {
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
if (!hostile && this._nodeSurplus(here, id) > 0) {
results.push({
...here,
tiles: current.tiles,
routeEnergyPerTonne: current.energy,
});
}
}
if (current.tiles >= RESOURCE_TRADE_RADIUS) continue;
for (const neighbour of this._neighbours(current.coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
const sea = tile.terrainClass === "Sea";
let mode;
if (sea) {
// A shipping lane needs a working port to load and unload.
if (!allowSea) continue;
mode = "ship";
} else {
// Ground legs must run on a road or railway; there is no off-road
// trucking in the trade network.
if (this.railways.has(nk)) mode = "train";
else if (this.roads.has(nk)) mode = "road";
else continue;
// Goods never cross the land of a nation you are at war with.
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
const stepEnergy = deliveryEnergyForResource(id, 1, 1, mode, tile.movementCostMultiplier || 1);
visited.add(nk);
queue.push({
coords: neighbour,
tiles: current.tiles + 1,
energy: current.energy + stepEnergy,
});
}
}
results.sort((a, b) => a.routeEnergyPerTonne - b.routeEnergyPerTonne);
return results;
},
_nodeSurplus(node, id) {
if (id === "hightech") return Math.max(0, node.stock.hightech || 0);
const population = node.city ? this.getCityEconomy(node.city).population : 0;
const perDay = id === "food"
? foodNeedPerDay(population)
: id === "steel"
? steelNeedPerDay(population)
: id === "luxury"
? luxuryNeedPerDay(population)
: 0;
return Math.max(0, (node.stock[id] || 0) - perDay * SUPPLIER_RESERVE_DAYS);
},
// A shortage of food, steel or luxury costs popularity; a grid starved of
// power costs popularity too. Each city is charged once per day.
_applyResourceShortages(ledger) {
for (const [cityId, shortage] of this._resourceShortages) {
const city = this._cityById.get(cityId);
if (!city) continue;
let penalty = 0;
for (const id of ["food", "steel", "luxury"]) {
penalty += (shortage[id] || 0) * RESOURCE_RULES.shortagePopularityPenalty;
}
if (penalty > 0) this._shiftOwnPopularity(city.civ, -penalty);
}
const civShortage = new Map();
for (const component of ledger.values()) {
if (component.civ < 0 || component.cityShortage <= 0) continue;
civShortage.set(
component.civ,
Math.max(civShortage.get(component.civ) || 0, component.cityShortage)
);
}
for (const [civ, shortage] of civShortage) {
this._shiftOwnPopularity(civ, -shortage * RESOURCE_RULES.shortagePopularityPenalty);
}
},
// An unpaid materials bill for the nation's upkeep means things are not being
// maintained; the people notice.
_civilianUpkeepShortage(civ, fraction) {
if (!(fraction > 0)) return;
this._shiftOwnPopularity(civ, -fraction * RESOURCE_RULES.shortagePopularityPenalty);
},
// ---------------------------------------------------------- repairs --
// Damage to a tile's structures leaves a steel debt; until it is paid down
// the scar does not heal and the tile's GDP recovery waits.
_recordRepairDebt(coords, damage) {
if (!(damage > 0)) return;
const k = key(coords.x, coords.y);
const debt = (this.tileRepairDebt.get(k) || 0) + damage * RESOURCE_RULES.repair.steelPerDamage;
this.tileRepairDebt.set(k, debt);
},
// A building level knocked down by bombardment waits to be rebuilt once its
// materials are paid for.
_queueCityRepair(city, index, level) {
const queue = this.cityRepairQueue.get(city.id) || [];
queue.push({ index, targetLevel: level });
this.cityRepairQueue.set(city.id, queue);
},
_tickRepairs() {
// Rebuild one lost city level per city per day, if the materials are there.
for (const [cityId, queue] of Array.from(this.cityRepairQueue)) {
const city = this._cityById.get(cityId);
if (!city || queue.length === 0) {
this.cityRepairQueue.delete(cityId);
continue;
}
const job = queue[0];
const proto = this.protoBuildings[job.index];
if (!proto) {
queue.shift();
continue;
}
const level = Math.max(1, job.targetLevel);
const buildCost = buildingBuildCost(proto, level - 1);
const cost = this.constructionResourceCost(proto, level - 1, buildCost);
if (!this._payConstructionResources(city.civ, city.coords, cost)) continue;
city.buildings[job.index] = level;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
queue.shift();
if (queue.length === 0) this.cityRepairQueue.delete(cityId);
}
// Pay down battle damage, then let the tile work heal.
for (const [k, debt] of Array.from(this.tileRepairDebt)) {
const coords = parseKey(k);
const civ = this.civAt(coords);
if (civ < 0) {
this.tileRepairDebt.delete(k);
continue;
}
const paid = this._payTileDebt(civ, coords, debt);
const remaining = debt - paid;
if (remaining <= 0.0001) this.tileRepairDebt.delete(k);
else this.tileRepairDebt.set(k, remaining);
}
for (const k of Array.from(this.tileImprovements.keys())) {
if (this.tileRepairDebt.has(k)) continue;
this._repairTileWork(parseKey(k));
}
},
// Spends what steel it can on a tile's repair: the owner's nearest city
// first, then the market. Returns the amount actually paid.
_payTileDebt(civ, coords, amount) {
if (!(amount > 0)) return 0;
const fromStock = this._drawFromNearest(civ, coords, { steel: amount, hightech: 0 });
const shortfall = amount - fromStock.steel;
let paid = fromStock.steel;
if (shortfall > 0 && !this._civMarketAccess(civ)) return paid;
const cost = shortfall * this._marketUnitCost("steel");
if (cost > 0) {
// The government can always pay, borrowing if the treasury is dry.
const fraction = 1;
this._spendBudget(civ, cost * fraction, "works");
paid += shortfall * fraction;
this._recordBudgetCategoryResource(civ, "works", "steel", cost * fraction);
this._recordResourceSpend(civ, "upkeep", cost * fraction, "steel");
}
return paid;
},
// Gradually restores a damaged tile work's hit points, at a steel cost.
_repairTileWork(coords) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return;
const max = TILE_IMPROVEMENT_HP[id] || 1000;
const hp = this.tileImprovementHp.get(k) || 0;
if (hp >= max) return;
const civ = this.tileImprovementOwner.get(k);
if (civ < 0) return;
const wanted = Math.min(RESOURCE_RULES.repair.tileHpPerDay, max - hp);
const cost = wanted * RESOURCE_RULES.repair.steelPerTileHp;
const paid = this._payTileDebt(civ, coords, cost);
const gained = Math.min(wanted, paid / RESOURCE_RULES.repair.steelPerTileHp);
if (gained > 0) this.tileImprovementHp.set(k, hp + gained);
},
// ------------------------------------------------------- unit supply --
// Land units only: aircraft have endurance and ships their own range.
_isLandUnitProto(proto) {
if (!proto || proto.air || proto.naval) return false;
return (proto.traversableTerrains || []).includes("Land");
},
_nearestAlliedCity(civ, coords) {
let best = null;
let bestDistance = Infinity;
for (const city of this.cities) {
if (city.civ !== civ) continue;
const distance = this.topology.tileDistance(city.coords, coords);
if (distance < bestDistance) {
bestDistance = distance;
best = city;
}
}
return best;
},
// The allied city a unit may draw food from, following the transport network:
// a unit at home or on a working port is fed at once; otherwise the nearest
// allied city reachable along roads, railways and shipping lanes (a port
// needed for the sea) supplies it. An encircled unit is cut off entirely.
_unitSupplyCity(unit) {
const civ = unit.civ;
if (this._isEncircled(unit.coords, civ)) return null;
const here = this.cityAt(unit.coords);
if (here && here.civ === civ) return here;
if (this._hasSeaSupply(unit.coords, civ)) return this._nearestAlliedCity(civ, unit.coords);
const visited = new Set([key(unit.coords.x, unit.coords.y)]);
const queue = [unit.coords];
while (queue.length > 0) {
const coords = queue.shift();
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile) continue;
const sea = tile.terrainClass === "Sea";
if (sea) {
// A shipping lane needs a working port at the nation's end.
if (!this._civMarketAccess(civ)) continue;
} else if (!(this.railways.has(nk) || this.roads.has(nk))) {
continue;
}
const city = this.cityAt(neighbour);
if (city && city.civ === civ) return city;
visited.add(nk);
queue.push(neighbour);
}
}
return null;
},
// Feeds a unit for a day from its supply city: the city's stores first, then
// the market on the treasury. Atomic: if neither can cover the ration, the
// city is not charged and the unit goes unfed.
_provisionUnit(unit, city, tonnes) {
if (!(tonnes > 0)) return true;
const store = this.getCityResourceStock(city);
const take = Math.min(store.food || 0, tonnes);
const remaining = tonnes - take;
if (remaining > 0.0001) {
// Feeding the unit from off-continent needs a working port.
if (!this._civMarketAccess(unit.civ)) return false;
const unitCost = this._marketUnitCost("food");
const cost = remaining * unitCost;
// The government can always pay, borrowing if the treasury is dry.
this._spendBudget(unit.civ, cost, "units");
this._recordBudgetCategoryResource(unit.civ, "units", "food", cost);
this._recordResourceSpend(unit.civ, "upkeep", cost, "food");
const market = this.resourceMarketStats.get("food");
if (market) market.demand += remaining;
}
store.food = (store.food || 0) - take;
return true;
},
// Every land unit eats a day from its carried stores. At home, or in foreign
// territory with a clear route to an allied city, it is fed and topped back
// up to a full pack; cut off, the days run down and then the soldiers start
// dying.
_tickUnitSupply() {
if (!this.resourceStock) return;
const battle = this._battleUnitIds();
const destroyed = [];
for (const unit of this.units) {
const proto = this.unitProto(unit);
if (!proto || !this._isLandUnitProto(proto)) continue;
const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
const ration = foodNeedPerDay(soldiers);
const here = this.cityAt(unit.coords);
const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp);
const multiplier = resting
? RESOURCE_RULES.infantryRestingFoodMultiplier
: battle.has(unit.id)
? RESOURCE_RULES.infantryCombatFoodMultiplier
: 1;
const city = this._unitSupplyCity(unit);
const fed = city ? this._provisionUnit(unit, city, ration * multiplier) : false;
if (fed) {
unit.foodDays = RESOURCE_RULES.unitSupplyDays;
} else {
unit.foodDays -= multiplier;
}
if (unit.foodDays <= 0) {
unit.hp -= unit.maxHp * RESOURCE_RULES.starvationHpFractionPerDay;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
}
if (destroyed.length > 0) {
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
this._visibilityDirty = true;
this._emitChanged();
}
},
_pruneResourceStock() {
for (const k of Array.from(this.resourceStock.keys())) {
const coords = parseKey(k);
if (!this._resourceNodeAt(coords)) this.resourceStock.delete(k);
}
},
// ------------------------------------------------------- research link --
// Science buildings spend a unit of high-tech for every research point. The
// nation's cities cover the bill up to what they hold; the rest of the
// points are simply not produced. Returns the effective points.
_spendHighTechForResearch(civ, points) {
if (points <= 0) return 0;
let needed = points * RESOURCE_RULES.highTechPerResearchPoint;
if (needed <= 0) return points;
let available = 0;
const cities = this.cities.filter((city) => city.civ === civ);
for (const city of cities) available += this.getCityResourceStock(city).hightech || 0;
if (available <= 0) return 0;
const ratio = Math.min(1, available / needed);
let toSpend = needed * ratio;
for (const city of cities) {
if (toSpend <= 0) break;
const store = this.getCityResourceStock(city);
const take = Math.min(store.hightech || 0, toSpend);
store.hightech = (store.hightech || 0) - take;
toSpend -= take;
}
return points * ratio;
},
};