// 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 grid in deficit imports power from a reachable // surplus. // // The daily tick is the heart of it: produce, satisfy electric demand, consume // what the people need, buy the rest from reachable stores -- the region first, // home regions next, foreign ones last -- then let shortages bite and prices // drift. There is no global market to fall back on. import { key, parseKey } from "../hex.js"; import { RESOURCE_IDS, STORABLE_RESOURCE_IDS, RESOURCE_RULES, RESOURCE_MARKET_BASE, RESOURCE_TRADE_RADIUS, FAMINE, tileImprovementById, isResourceTileBuilding, resourceById, MONEY, } from "../data.js"; import { cityEnergyPerDay, foodNeedPerDay, tileFoodOutput, steelNeedPerDay, luxuryNeedPerDay, foodMonthlyTarget, producerEnergyPerDay, foodSynthesisEnergy, synthesisedFoodFor, deliveryEnergyForResource, nextMarketPrice, protoUpkeep, constructionResourceCost as sharedConstructionResourceCost, } from "../resources.js"; import { buildingBuildCost } from "../rules.js"; import { Random } from "../rng.js"; import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; import { EFFECT_RESEARCH } from "../data/effects.js"; import { NEWS_FAMINE } from "../data/relations.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(); // The regions whose food store has run dry and cannot feed their people. this._famine = new Set(); 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 bought from outside its border over the day, with the // quantity and the money it moved, per resource. this.resourceExternal = new Map(); // The day's resource-shortage notices, per nation: a producing building that // could not get the material or power it needs. Cleared each day and shipped // to that nation's own viewer only. this._resourceAlerts = 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); }, // Records that a nation bought a resource from outside its border: a foreign // neighbour or the world market. `value` is the money paid, in the buyer's // national currency. Same-nation purchases are not external and are skipped // by the callers. _recordExternalBuy(civ, id, qty, value) { if (civ < 0 || !(qty > 0)) return; this._externalRecord(civ, "buy", id, qty, value); }, // Records that a nation sold a resource across its border, and the money it // was paid, in the seller's national currency. _recordExternalSell(civ, id, qty, value) { if (civ < 0 || !(qty > 0)) return; this._externalRecord(civ, "sell", id, qty, value); }, _externalRecord(civ, side, id, qty, value) { if (!this.resourceExternal) this.resourceExternal = new Map(); let record = this.resourceExternal.get(civ); if (!record) { record = { buy: {}, sell: {} }; this.resourceExternal.set(civ, record); } const entry = record[side][id] || (record[side][id] = { qty: 0, value: 0 }); entry.qty += qty; if (value > 0) entry.value += value; }, // One nation's cross-border buying and selling over the last day, as plain // per-resource { qty, value } objects for the Economy tab's trade table. _civExternalTrade(civ) { const record = this.resourceExternal ? this.resourceExternal.get(civ) : null; const buy = {}; const sell = {}; for (const id of RESOURCE_IDS) { const b = record && record.buy[id]; const s = record && record.sell[id]; buy[id] = { qty: b ? b.qty : 0, value: b ? b.value : 0 }; sell[id] = { qty: s ? s.qty : 0, value: s ? s.value : 0 }; } return { buy, sell }; }, _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); if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k); // 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) + this.resourceBuildingOutputAt(k, proto); } 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 price a resource carried at the last daily update, for the day-on-day // arrows. Falls back to today's price before the first update. getResourcePrevPrice(id) { const previous = this._prevResourcePrices ? this._prevResourcePrices.get(id) : undefined; return previous === undefined ? this.getResourcePrice(id) : previous; }, // The consumption-weighted move in the world's commodity prices over the last // day, a positive fraction when they rose overall. Each commodity is weighted // by what the world consumed of it, valued at today's price, so a dear // commodity the world uses little of does not swamp the index. _recordCommodityInflation(market, previousPrices) { this._prevResourcePrices = previousPrices; let now = 0; let before = 0; for (const id of RESOURCE_IDS) { const stats = market.get(id); const weight = Math.max(0, stats ? stats.demand : 0); if (weight <= 0) continue; const price = this.getResourcePrice(id); const previous = previousPrices.get(id) || price; now += weight * price; before += weight * previous; } this.commodityInflation = before > 0 ? now / before - 1 : 0; }, // 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 }, }; }, // What one region holds and draws per day, for its city panel: the reserve of // every storable resource, that reserve's market value, and the private // sector's daily consumption -- the people's staples, the region's building // upkeep and the grid power its industry pulls. getRegionResourceInfo(city) { const economy = this.getCityEconomy(city); const population = economy.population; const store = this.getCityResourceStock(city); const consumption = { food: foodNeedPerDay(population), steel: steelNeedPerDay(population), luxury: luxuryNeedPerDay(population), hightech: 0, energy: cityEnergyPerDay(economy.gdp), }; for (const [index, level] of Object.entries(city.buildings || {})) { if (level <= 0) continue; const proto = this.protoBuildings[Number(index)]; if (!proto) continue; const costs = this.buildingUpkeepResources(proto, level); consumption.steel += costs.steel || 0; consumption.energy += costs.energy || 0; consumption.hightech += costs.hightech || 0; } const stock = {}; let value = 0; for (const id of STORABLE_RESOURCE_IDS) { const amount = store[id] || 0; stock[id] = amount; value += amount * this.getResourcePrice(id); } return { stock, consumption, value }; }, // The region's unmet resource needs over the last day: for each resource the // share of the day's need it could not buy, and the amount that stands for, // so the People tab can say what ran short and by how much. getRegionShortages(city) { const fractions = (this._resourceShortages && this._resourceShortages.get(city.id)) || {}; const population = this.getCityEconomy(city).population; const needs = { food: foodNeedPerDay(population), steel: steelNeedPerDay(population), luxury: luxuryNeedPerDay(population), }; const shortages = {}; for (const id of ["food", "steel", "luxury"]) { const fraction = fractions[id] || 0; if (!(fraction > 0)) continue; shortages[id] = { fraction, amount: fraction * needs[id] }; } return shortages; }, // 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 += this.resourceBuildingOutputAt(k, proto); } else { // A converter makes only what it can sell, so report the throttled // figure the daily tick used rather than the nameplate capacity. const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1; production[proto.resource] += this.resourceBuildingOutputAt(k, proto) * throttle; } } // The land's harvest is part of what the nation produces, and so is the // extra food its regions force from energy when the harvest falls short. 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; } if (this._foodSynthesis) { for (const city of this.cities) { if (city.civ !== civ) continue; production.food += this._foodSynthesis.get(city.id) || 0; } } 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), // What the nation bought from and sold outside its border over the day, // with the money it moved, for the Economy tab's consumption table. external: this._civExternalTrade(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 own cities // is bought first (the region, then its neighbours), each region paid for the // material into its private cash; the rest is bought from reachable foreign // regions. There is no global market, so a bill the reachable stores cannot // cover refuses the construction. Energy is a flow rather than a store, so it // is not procured here. _payConstructionResources(civ, coords, resources, category = "construction", source = null) { 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 payer = this._treasuryPayer(civ); const fromStock = this._drawFromNearest(civ, coords, { steel, hightech }, payer); const shortfall = { steel: steel - fromStock.steel, hightech: hightech - fromStock.hightech, }; const v = this.currencyValue(civ); // What the nation's own stores supplied is paid to the owning regions. let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v; let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v; // 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") ); let covered = shortfall.steel <= 0 && shortfall.hightech <= 0; if (!covered) { const city = this._nearestCity(civ, coords); if (city) { const nodes = this._resourceNodes(); const haul = { steel: 0, hightech: 0 }; let gotSteel = 0; let gotTech = 0; if (shortfall.steel > 0) { const before = payer.total; gotSteel = this._procureFromNeighbours(city, "steel", shortfall.steel, haul, nodes, { payer }); spentSteel += payer.total - before; } if (shortfall.hightech > 0) { const before = payer.total; gotTech = this._procureFromNeighbours(city, "hightech", shortfall.hightech, haul, nodes, { payer }); spentTech += payer.total - before; } covered = gotSteel + 1e-6 >= shortfall.steel && gotTech + 1e-6 >= shortfall.hightech; } } if (spentSteel > 0) { this._recordBudgetCash(civ, category, -spentSteel); this._recordBudgetCategoryResource(civ, category, "steel", spentSteel, source); } if (spentTech > 0) { this._recordBudgetCash(civ, category, -spentTech); this._recordBudgetCategoryResource(civ, category, "hightech", spentTech, source); } if (spentSteel + spentTech > 0) { this._recordResourceSpend(civ, "construction", spentSteel + spentTech, { steel: spentSteel, hightech: spentTech, }); } return covered; }, // Pulls each material from the nearest of the nation's cities to `coords`, // capped by what that city holds, and buys it: with a `payer` the state pays // the owning region the material's value into its private cash, so the store // is never taken for free. Returns how much of each was taken. _drawFromNearest(civ, coords, wanted, payer = null) { 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; this._payRegionForMaterial(city, id, take, payer); } if (remaining.steel <= 0 && remaining.hightech <= 0) break; } return taken; }, // The state buys a material out of a region's store: the region is paid the // material's value into its private cash and the payer (the treasury) carries // the bill. Without a payer there is nothing to settle. _payRegionForMaterial(city, id, amount, payer) { if (!(amount > 0) || !payer) return; const cost = amount * this.getResourcePrice(id) / this.currencyValue(city.civ); payer.charge(cost); this._creditRegionCash(city, this.currencyOf(city.civ).code, cost); }, // ---------------------------------------------------------- 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, parts: [] }, units: { steel: 0, energy: 0, hightech: 0, parts: [] }, works: { steel: 0, energy: 0, hightech: 0, parts: [] }, transport: { steel: 0, energy: 0, hightech: 0, parts: [] }, }; // `label` names the thing that wore out -- a building, unit, work or // transport type -- so the budget row can show what consumed each resource. const add = (category, costs, label = null) => { const steel = costs.steel || 0; const energy = costs.energy || 0; const hightech = costs.hightech || 0; category.steel += steel; category.energy += energy; category.hightech += hightech; if (label) category.parts.push({ label, steel, energy, hightech }); }; 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), proto.name); } } 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, }, proto.name); } for (const [k, id] of this.tileImprovements) { if (this.tileImprovementOwner.get(k) !== civ) continue; const proto = tileImprovementById(id); if (!proto) continue; const costs = protoUpkeep(proto, proto.buildCost || 0); if (proto.fuelEnergyPerDay) costs.energy = (costs.energy || 0) + proto.fuelEnergyPerDay; add(categories.works, costs, proto.name); } const counts = this._transportTiles(civ); if (counts.road > 0) { add(categories.transport, { steel: counts.road * RESOURCE_RULES.transportUpkeepSteel.road, }, "Roads"); } if (counts.railway > 0) { add(categories.transport, { steel: counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway, }, "Railways"); } // Merge the per-thing parts into one entry per label, so a building type // spread over several cities is a single line in the budget. for (const category of Object.values(categories)) { const byLabel = new Map(); for (const part of category.parts) { const merged = byLabel.get(part.label) || { label: part.label, steel: 0, energy: 0, hightech: 0 }; merged.steel += part.steel; merged.energy += part.energy; merged.hightech += part.hightech; byLabel.set(part.label, merged); } category.sources = Array.from(byLabel.values()); delete category.parts; } 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 and, with a `payer`, buys it from // each owning region into its private cash. Returns what is still missing. _drawFromCities(civ, id, amount, payer = null) { 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; this._payRegionForMaterial(city, id, take, payer); } return remaining; }, // Settles one nation's daily upkeep materials: buy the stores from the regions // that hold them, buy the rest from reachable regions, and return the fraction // of the bill the nation could actually settle. With no global market, a bill // the reachable stores cannot cover is left unmet, and the rest is unrest. _settleUpkeep(civ, categories) { const ids = ["buildings", "units", "works", "transport"]; const steelUnit = this._marketUnitCost("steel"); const techUnit = this._marketUnitCost("hightech"); const payer = this._treasuryPayer(civ); let totalSteel = 0; let totalTech = 0; let drawnSteel = 0; let drawnTech = 0; let drawnSteelCost = 0; let drawnTechCost = 0; const shortfalls = {}; // Draw each category's demand from the shared city stores, paying the owning // region. 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 beforeSteel = payer.total; const steelShort = steel > 0 ? this._drawFromCities(civ, "steel", steel, payer) : 0; drawnSteelCost += payer.total - beforeSteel; const beforeTech = payer.total; const techShort = hightech > 0 ? this._drawFromCities(civ, "hightech", hightech, payer) : 0; drawnTechCost += payer.total - beforeTech; shortfalls[id] = { steel: steelShort, hightech: techShort }; totalSteel += steel; totalTech += hightech; drawnSteel += steel - steelShort; drawnTech += hightech - techShort; } const steelShortTotal = totalSteel - drawnSteel; const techShortTotal = totalTech - drawnTech; // 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 (totalSteel <= 0 && totalTech <= 0) return 1; // The shortfall is bought region by region, each city taking its share, from // the nearest reachable stores -- home regions first, then foreign ones. const cities = this.cities.filter((city) => city.civ === civ); const shares = this._populationShares(cities); const nodes = this._resourceNodes(); let gotSteel = 0; let gotTech = 0; let spentSteel = drawnSteelCost; let spentTech = drawnTechCost; for (let i = 0; i < cities.length; i++) { const city = cities[i]; const allowSea = this._cityMarketAccess(city); const haul = { steel: 0, hightech: 0 }; if (steelShortTotal > 0) { const before = payer.total; gotSteel += this._procureFromNeighbours( city, "steel", steelShortTotal * shares[i], haul, nodes, { payer, allowSea } ); spentSteel += payer.total - before; } if (techShortTotal > 0) { const before = payer.total; gotTech += this._procureFromNeighbours( city, "hightech", techShortTotal * shares[i], haul, nodes, { payer, allowSea } ); spentTech += payer.total - before; } } if (spentSteel + spentTech > 0) { this._recordResourceSpend(civ, "upkeep", spentSteel + spentTech, { steel: spentSteel, hightech: spentTech, }); // Split the cash across the categories that wore the material, then across // the buildings, units, works and transport that consumed it. for (const id of ids) { const demand = categories[id] || {}; const steelCash = totalSteel > 0 ? spentSteel * ((demand.steel || 0) / totalSteel) : 0; const techCash = totalTech > 0 ? spentTech * ((demand.hightech || 0) / totalTech) : 0; const cash = steelCash + techCash; if (cash > 0) this._recordBudgetCash(civ, id, -cash); const sources = (categories[id] && categories[id].sources) || []; if (steelCash > 0) { this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel"); } if (techCash > 0) { this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech"); } } } const Psteel = this.getResourcePrice("steel"); const Ptech = this.getResourcePrice("hightech"); const fullValue = totalSteel * Psteel + totalTech * Ptech; if (!(fullValue > 0)) return 1; const settled = (drawnSteel + gotSteel) * Psteel + (drawnTech + gotTech) * Ptech; return Math.min(1, settled / fullValue); }, // Files a category's market purchase of one resource under the sources that // consumed it -- the building, unit, work or transport type -- split in // proportion to how much of that resource each source wore out. _recordResourceBySource(civ, category, resource, cash, sources, field) { let total = 0; for (const source of sources) total += source[field] || 0; if (!(total > 0)) { this._recordBudgetCategoryResource(civ, category, resource, cash); return; } let remaining = cash; for (let i = 0; i < sources.length; i++) { const source = sources[i]; const amount = i === sources.length - 1 ? remaining : cash * ((source[field] || 0) / total); remaining -= amount; if (amount > 0) { this._recordBudgetCategoryResource(civ, category, resource, amount, source.label); } } }, // ------------------------------------------------------ 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 from reachable // regions. The payment is atomic -- if the reachable stores cannot cover it, // the drawn stock is 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 coords = this._anyCityCoords(civ); const payer = this._treasuryPayer(civ); const drawn = this._drawFromNearest(civ, coords, wanted, payer); const missing = { steel: wanted.steel - drawn.steel, hightech: wanted.hightech - drawn.hightech, }; const v = this.currencyValue(civ); let gotSteel = 0; let gotTech = 0; let spentSteel = drawn.steel * this.getResourcePrice("steel") / v; let spentTech = drawn.hightech * this.getResourcePrice("hightech") / v; let covered = missing.steel <= 0 && missing.hightech <= 0; if (!covered) { const city = this._nearestCity(civ, coords); if (city) { const nodes = this._resourceNodes(); const haul = { steel: 0, hightech: 0 }; if (missing.steel > 0) { const before = payer.total; gotSteel = this._procureFromNeighbours(city, "steel", missing.steel, haul, nodes, { payer }); spentSteel += payer.total - before; } if (missing.hightech > 0) { const before = payer.total; gotTech = this._procureFromNeighbours(city, "hightech", missing.hightech, haul, nodes, { payer }); spentTech += payer.total - before; } covered = gotSteel + 1e-6 >= missing.steel && gotTech + 1e-6 >= missing.hightech; } } if (spentSteel > 0) { this._recordBudgetCash(civ, "military", -spentSteel); this._recordBudgetCategoryResource(civ, "military", "steel", spentSteel); } if (spentTech > 0) { this._recordBudgetCash(civ, "military", -spentTech); this._recordBudgetCategoryResource(civ, "military", "hightech", spentTech); } if (spentSteel + spentTech > 0) { this._recordResourceSpend(civ, "combat", spentSteel + spentTech, { steel: spentSteel, hightech: spentTech, }); } return covered; }, _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.resourceExternal = new Map(); this._resourceShortages = new Map(); this._resetTaxLedger(); 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(); // The land's harvest per region, so the synthesis pass below knows who is // short of what its people eat. const regionFood = 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; regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced); pendingFood.push({ cityId: city.id, coords: city.coords, component: components.get(k), amount: produced, }); const component = ledger.get(components.get(k)); if (component) { const foodPower = producerEnergyPerDay("food", produced); component.foodEnergy += foodPower; component.foodConsumers.push({ city, amount: foodPower }); } } // A region whose own harvest falls short of what its people eat can force // extra food from energy -- greenhouses and synthesis. Growing is cheapest // for the first tonnes, so the region grows while the marginal energy for // another tonne beats buying, then completes with the dearer option: what // the reachable market cannot actually supply, it grows itself. const foodPrice = this.getResourcePrice("food"); const energyPrice = this.getResourcePrice("energy"); const nodes = this._resourceNodes(); this._foodSynthesis = new Map(); for (const city of this.cities) { const produced = regionFood.get(city.id) || 0; const need = foodNeedPerDay(this.getCityEconomy(city).population); const shortfall = need - produced; if (!(shortfall > 0)) continue; const allowSea = this._cityMarketAccess(city) && !this._isEncircled(city.coords, city.civ); // The food the region can actually buy: the reachable surplus. A distant // island reaches none, so it must grow every tonne itself. let buyable = 0; for (const supplier of this._nearbySuppliers(city, "food", nodes, allowSea)) { buyable += this._nodeSurplus(supplier, "food"); } const extra = synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable); if (!(extra > 0)) continue; const componentId = components.get(key(city.coords.x, city.coords.y)); const component = ledger.get(componentId); if (!component) continue; const energy = foodSynthesisEnergy(extra); component.foodEnergy += energy; component.foodConsumers.push({ city, amount: energy }); pendingFood.push({ cityId: city.id, coords: city.coords, component: componentId, amount: extra, }); this._foodSynthesis.set(city.id, extra); } // Settle every connected grid: what the plants make against what the cities // and industry draw. A grid that runs short imports power from the reachable // grids that have a surplus -- there is no global market to fall back on. for (const component of ledger.values()) { if (component.civ < 0) continue; component.demand = component.cityEnergy + component.converterEnergy + component.foodEnergy; component.imports = 0; } this._tradeGridPower(ledger, components); for (const component of ledger.values()) { if (component.civ < 0) continue; component.available = component.supply + component.imports; // Cities are served first, then the food the region grows, then the // converters: a famine is worse than an idle factory, so the power that // feeds people is never throttled away by industry. const cityServed = Math.min(component.cityEnergy, component.available); component.cityShortage = component.cityEnergy > 0 ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy)) : 0; const afterCity = Math.max(0, component.available - cityServed); const foodServed = Math.min(component.foodEnergy, afterCity); component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1; const afterFood = afterCity - foodServed; component.industrialScale = component.converterEnergy > 0 ? Math.max(0, Math.min(1, afterFood / component.converterEnergy)) : 1; market.get("energy").supply += component.supply; market.get("energy").demand += component.demand; } // A grid that cannot power its industry names the works left idle. this._noteGridPowerShortages(ledger, components); // Food lands in the region's store, scaled by the power the grid could give // it. Food is served before the converters, so the harvest survives a // power squeeze that idles the factories. for (const entry of pendingFood) { const component = ledger.get(entry.component); const scale = component ? component.foodScale : 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 throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1; const produced = this.resourceBuildingOutputAt(k, proto) * throttle * 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 reachable // stores -- home regions first, then foreign ones. this._consumeCityResources(components, market, nodes); // Power is a paid private flow: the grid's consumers settle with its plants, // after the day's sales so an agent can pay from what it just earned. this._settleGridPower(ledger); // A region that could not feed its people is in famine: it tells the world, // and its people start dying. this._tickFamine(); // 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. The prices it // replaces are kept, so the panel can show each commodity's day-on-day move // and the consumption-weighted inflation it adds up to. const previousPrices = new Map(); for (const id of RESOURCE_IDS) previousPrices.set(id, this.getResourcePrice(id)); 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._recordCommodityInflation(market, previousPrices); 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(); // Each converter's production scale for the day, so its power draw matches // what it actually intends to make. Read again by the production loop. this._converterScales = new Map(); for (const [k, id] of components) { if (ledger.has(id)) continue; ledger.set(id, { civ: this.territory.get(k), supply: 0, // The draw that makes up the physical balance: cities and their // buildings, the converters and the land's food, plus the works' wear // and the plants' own fuel as internal losses. cityEnergy: 0, converterEnergy: 0, foodEnergy: 0, fuelEnergy: 0, // The part of `cityEnergy` the regions actually pay for, so the works' // and plants' internal losses are not billed to them. billableCityEnergy: 0, demand: 0, available: 0, industrialScale: 1, foodScale: 1, cityShortage: 0, // Who makes the power and who draws it, so the grid's bill can be // settled between them: the plants sell, the converters pay from their // own cash and the regions pay for their cities and their food. producers: [], cities: [], converters: [], foodConsumers: [], imports: 0, exports: 0, importCost: 0, }); } for (const city of this.cities) { const component = ledger.get(components.get(key(city.coords.x, city.coords.y))); if (!component) continue; let amount = 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) amount += this.buildingUpkeepResources(proto, level).energy; } component.cityEnergy += amount; component.billableCityEnergy += amount; component.cities.push({ city, amount }); } 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)) { if (proto.resource === "energy") { const output = this.resourceBuildingOutputAt(k, proto); component.supply += output; component.producers.push({ k, output }); } else { const agent = this.buildingAgents ? this.buildingAgents.get(k) : null; const scale = this._converterProductionScale(k, proto, agent); this._converterScales.set(k, scale); const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale; component.converterEnergy += amount; component.converters.push({ k, amount }); } if (proto.fuelEnergyPerDay) { const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay"); component.fuelEnergy += fuel; component.cityEnergy += fuel; } } // Every work's operating power is a draw on the grid; it rides with the // cities and is not billed back to a region. component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy; } return ledger; }, // Moves power between grids. A grid in deficit buys from the reachable grids // that hold a surplus, cheapest route first; power is a flow with no store, so // it flows the same day. The seller's plants are paid for the power, and the // buyer's consumers carry the cost. No government is involved. _tradeGridPower(ledger, components) { const price = this.getResourcePrice("energy"); const surplus = new Map(); for (const [id, component] of ledger) { component.imports = 0; component.exports = 0; component.importCost = 0; surplus.set(id, Math.max(0, component.supply - component.demand)); } for (const [id, component] of ledger) { if (component.civ < 0) continue; const deficit = Math.max(0, component.demand - component.supply); if (deficit <= 0) continue; const origin = this._componentCity(id, components); if (!origin) continue; const reach = this._reachableComponents(origin, components, this._cityMarketAccess(origin)); const suppliers = Array.from(reach.entries()) .filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0) .sort((a, b) => a[1] - b[1]); let needed = deficit; for (const [sid, routeEnergy] of suppliers) { if (needed <= 0) break; const available = surplus.get(sid) || 0; const take = Math.min(needed, available); if (!(take > 0)) continue; // The power costs its price plus the energy burned carrying it. A unit // of energy weighs `tonnesPerUnit`, so the freight is charged on the // real tonnage moved, not as if every kWh were a tonne. const tonnes = take * (resourceById("energy")?.tonnesPerUnit || 1); const cost = take * price + tonnes * routeEnergy * price; const seller = ledger.get(sid); this._payGridProducers(seller, cost, take); if (seller.civ !== component.civ) { this._recordExternalBuy(component.civ, "energy", take, cost); this._recordExternalSell(seller.civ, "energy", take, cost); } surplus.set(sid, available - take); component.imports += take; component.importCost += cost; seller.exports += take; needed -= take; } } }, // Settles one grid's power bill: the regions pay for their cities' and their // food's draw, the converters pay for their own from their cash, and the // plants are paid for what was actually delivered. The works' wear and the // plants' fuel are the grid's internal losses, folded into the price. _settleGridPower(ledger) { const price = this.getResourcePrice("energy"); for (const component of ledger.values()) { if (component.civ < 0) continue; const cityServed = component.cityEnergy * (1 - (component.cityShortage || 0)); const billableCityServed = Math.min(cityServed, component.billableCityEnergy); const foodServed = component.foodEnergy * (component.foodScale ?? 1); const converterServed = component.converterEnergy * (component.industrialScale ?? 1); const billableServed = billableCityServed + foodServed + converterServed; if (!(billableServed > 0)) continue; const totalConsumed = cityServed + foodServed + converterServed; const ownUsed = Math.min(component.supply, totalConsumed); const bill = ownUsed * price + (component.importCost || 0); const unitPrice = bill / billableServed; const code = this.currencyOf(component.civ).code; const cityFactor = component.billableCityEnergy > 0 ? billableCityServed / component.billableCityEnergy : 0; const foodFactor = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 0; const converterFactor = component.converterEnergy > 0 ? converterServed / component.converterEnergy : 0; for (const entry of component.cities || []) { const cost = entry.amount * cityFactor * unitPrice; if (cost > 0) this._chargeRegionCash(entry.city, code, cost); } for (const entry of component.foodConsumers || []) { const cost = entry.amount * foodFactor * unitPrice; if (cost > 0) this._chargeRegionCash(entry.city, code, cost); } for (const entry of component.converters || []) { const cost = entry.amount * converterFactor * unitPrice; if (cost > 0) this._chargeBuildingCash(entry.k, code, cost); } this._payGridProducers(component, ownUsed * price, ownUsed); } }, // One city that sits in grid component `id`, the origin for a power trade. _componentCity(id, components) { for (const city of this.cities) { if (components.get(key(city.coords.x, city.coords.y)) === id) return city; } return null; }, // The grid components reachable from a city over the transport network, mapped // to the least energy it takes to carry a unit there. A sea leg needs a // working port; a land leg needs road or railway; goods never cross the land // of a nation you are at war with. _reachableComponents(city, components, allowSea = true) { const reach = new Map(); const origin = key(city.coords.x, city.coords.y); const originComponent = components.get(origin); const visited = new Set([origin]); const queue = [{ coords: city.coords, energy: 0 }]; while (queue.length > 0) { const current = queue.shift(); 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) { if (!allowSea) continue; mode = "ship"; } else { if (this.railways.has(nk)) mode = "train"; else if (this.roads.has(nk)) mode = "road"; else continue; const owner = this.civAt(neighbour); if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue; } const stepEnergy = deliveryEnergyForResource( "food", 1, 1, mode, tile.movementCostMultiplier || 1 ); visited.add(nk); const energy = current.energy + stepEnergy; const component = components.get(nk); if (component !== undefined && component !== originComponent) { const best = reach.get(component); if (best === undefined || energy < best) reach.set(component, energy); } queue.push({ coords: neighbour, energy }); } } return reach; }, _consumeCityResources(components, market, nodes = null) { nodes = 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); // A shipping lane needs a working port; an encircled city is cut off from // every neighbour, home or foreign. const allowSea = !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, { allowSea }); } // 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 region whose food ran out and could not be resupplied is in famine. The // world is told the day it begins, once per famine rather than daily, and the // region loses a share of its people every day it lasts. Food coming back ends // it silently. _tickFamine() { for (const city of this.cities) { const shortage = (this._resourceShortages && this._resourceShortages.get(city.id)) || {}; const starving = (shortage.food || 0) > 0; const was = this._famine.has(city.id); if (starving && !was) { this._famine.add(city.id); this._addNews({ type: NEWS_FAMINE, civ: city.civ, region: city.name }); } else if (!starving && was) { this._famine.delete(city.id); } if (starving) this._applyFamineDeaths(city); } }, // Famine kills a fixed share of a region's people each day, spread over its // tiles in proportion to where they live. _applyFamineDeaths(city) { let total = 0; const tiles = []; for (const coords of this.regionTiles(city)) { const k = key(coords.x, coords.y); const population = this.tilePopulation.get(k) || 0; if (population <= 0) continue; tiles.push({ k, population }); total += population; } if (total <= 0) return; for (const tile of tiles) { this.tilePopulation.set(tile.k, tile.population * (1 - FAMINE.dailyMortality)); } this._populationVersion += 1; }, // Whether a region is currently in famine, for its city panel. isFamine(city) { return !!(this._famine && this._famine.has(city.id)); }, // A city buys a material from the stores it can reach over the transport // network. There is no global market: if no reachable store holds enough, the // rest is simply not bought. Suppliers in the buyer's own nation come first; // foreign regions are considered only to cover what home cannot. Within a // tier the cheapest delivered price wins, and a surplus several buyers chase // is bid up. // // The region pays from its own private cash by default; when `payer` is given // (a government buy) the treasury pays instead. A foreign seller is paid in // its national currency, acquired from that nation's central bank. Returns the // amount bought and records each leg on the delivery graph. _procureFromNeighbours(city, id, amount, cityStore, nodes, options = {}) { const { allowSea = true, payer = null, foreignOnly = false } = options; 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 foreign = supplier.civ >= 0 && supplier.civ !== city.civ; if (foreignOnly && !foreign) 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 premium = Math.min( MONEY.maxBidMultiplier, 1 + MONEY.bidPremium * (claimed / Math.max(1e-9, surplus)) ); const goods = price * premium; const freight = (supplier.routeEnergyPerTonne || 0) * energyPrice; quotes.push({ supplier, available, foreign, goods, freight, unitCost: goods + freight }); } // Home regions before foreign ones; then by delivered price. quotes.sort((a, b) => (a.foreign - b.foreign) || (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 value = (goods + freight) * bought; const vBuyer = this.currencyValue(city.civ); const charge = payer ? (cost) => payer.charge(cost) : (cost) => this._chargeRegionCash(city, buyerCode, cost); // The seller is paid the goods' value in its own currency -- a home // neighbour in the shared national currency, a foreigner after a trip to // its central bank. Freight rides with the goods. if (foreign) { // The seller's government charges the foreign buyer an export tariff, // in the seller's currency, on top of the goods. const exportTax = value * this.getTaxRate(supplier.civ, "export", id); const acquired = this._regionAcquireCurrency( city, supplier.civ, value + exportTax, charge, !payer ); if (!acquired) continue; const total = value + exportTax; const sellerShare = total > 0 ? value / total : 1; const sellerAmount = acquired.amount * sellerShare; this._paySupplier(supplier, acquired.code, sellerAmount); if (acquired.amount > sellerAmount) { const sellerCity = supplier.city || this._regionCityNear(supplier.civ, supplier.coords); this._recordTax( supplier.civ, "export", id, acquired.amount - sellerAmount, sellerCity ); } // The buyer's own government taxes the national currency it had to spend // to buy the foreign currency (nothing held means nothing spent). if (!payer && acquired.spent > 0) { const importRate = this.getTaxRate(city.civ, "import", id); if (importRate > 0) { const importTax = acquired.spent * importRate; charge(importTax); this._recordTax(city.civ, "import", id, importTax, city); } } this._recordResourceTrade(city.civ, supplier.civ, id, bought); this._recordExternalBuy(city.civ, id, bought, value / vBuyer); this._recordExternalSell(supplier.civ, id, bought, value / this.currencyValue(supplier.civ)); this._recordCurrencyDemand(supplier.civ, value); this._recordCurrencyDemand(city.civ, -value); } else { // A region buying from another region of its own country pays a sales // tax on top of the price; the seller still gets the full value, and the // government keeps the tax. Government purchases are not taxed. const homeCost = value / vBuyer; const salesTax = payer ? 0 : homeCost * this.getTaxRate(city.civ, "sales", id); charge(homeCost + salesTax); this._paySupplier(supplier, buyerCode, homeCost); if (salesTax > 0) this._recordTax(city.civ, "sales", id, salesTax, city); } 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; // A production building's sale feeds the demand trend it grows on. if (supplier.kind === "building") { this._recordBuildingSale(key(supplier.coords.x, supplier.coords.y), 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 producer that owns a surplus. A resource building is an // independent private-sector agent, so its goods are paid into its own cash; // a city's surplus is paid to the region that stores it. _paySupplier(node, code, amount) { if (!(amount > 0)) return; if (node.kind === "building") { this._creditBuildingCash(key(node.coords.x, node.coords.y), code, amount); return; } const city = node.city || this._regionCityNear(node.civ, node.coords); if (city) this._creditRegionCash(city, code, amount); }, // A payer that draws on a nation's treasury, so a government purchase settles // with the supplier while the treasury carries the bill (and may go into // overdraft). `total` accumulates what was spent. _treasuryPayer(civ) { const payer = { total: 0 }; payer.charge = (cost) => { if (!(cost > 0)) return; this.budgets.set(civ, this.getBudget(civ) - cost); payer.total += cost; }; return payer; }, // The nation's city nearest `coords`, the region that buys on its behalf when // the government procures away from a specific city. _nearestCity(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 market's delivered price per unit: the good plus the energy a nominal // two-tile road delivery burns. Used to value materials, not to source them. _marketUnitCost(id) { const energyPerUnit = deliveryEnergyForResource(id, 1, 2, "road", 1); return this.getResourcePrice(id) + energyPerUnit * this.getResourcePrice("energy"); }, _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 stores first, then // reachable regions. Returns the amount actually paid. _payTileDebt(civ, coords, amount) { if (!(amount > 0)) return 0; const payer = this._treasuryPayer(civ); const fromStock = this._drawFromNearest(civ, coords, { steel: amount, hightech: 0 }, payer); const shortfall = amount - fromStock.steel; let paid = fromStock.steel; let spent = fromStock.steel * this.getResourcePrice("steel") / this.currencyValue(civ); if (shortfall > 0) { const city = this._nearestCity(civ, coords); if (city) { const nodes = this._resourceNodes(); const haul = { steel: 0, hightech: 0 }; const before = payer.total; paid += this._procureFromNeighbours(city, "steel", shortfall, haul, nodes, { payer }); spent += payer.total - before; } } if (spent > 0) { this._recordBudgetCash(civ, "works", -spent); this._recordBudgetCategoryResource(civ, "works", "steel", spent); this._recordResourceSpend(civ, "upkeep", spent, "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; const payer = this._treasuryPayer(unit.civ); let procuredSpent = 0; if (remaining > 0.0001) { // The rest comes from reachable regions, bought by the treasury. const nodes = this._resourceNodes(); const haul = { food: 0 }; const before = payer.total; const got = this._procureFromNeighbours(city, "food", remaining, haul, nodes, { payer }); procuredSpent = payer.total - before; if (got + 1e-6 < remaining) { // Not enough food reachable: the unit goes without, and what was bought // stays in the city's store. store.food = (store.food || 0) + got; this._recordProvisionSpend(unit.civ, 0, procuredSpent); return false; } } store.food = (store.food || 0) - take; // The city's own food is bought from the region that stored it. const takeValue = take * this.getResourcePrice("food") / this.currencyValue(unit.civ); this._payRegionForMaterial(city, "food", take, payer); this._recordProvisionSpend(unit.civ, takeValue, procuredSpent); return true; }, // Books the food a unit ate, whether it came from a region's store or from a // foreign one. _recordProvisionSpend(civ, takeValue, procuredSpent) { const spent = takeValue + procuredSpent; if (!(spent > 0)) return; this._recordBudgetCash(civ, "units", -spent); this._recordBudgetCategoryResource(civ, "units", "food", spent); this._recordResourceSpend(civ, "upkeep", spent, "food"); }, // 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 -- // One city's science buildings spend a unit of high-tech for every research // point they make, up to what the city holds; the rest of the points are not // produced. A city that cannot cover its buildings' need raises a shortage // notice. Returns the effective points. _spendCityHighTechForResearch(civ, city, points) { if (points <= 0) return 0; const needed = points * RESOURCE_RULES.highTechPerResearchPoint; const store = this.getCityResourceStock(city); const have = store.hightech || 0; if (needed <= 0) return points; if (have + 1e-9 >= needed) { store.hightech = have - needed; return points; } this._noteResourceShortage(civ, this._cityResearchBuilding(city), city.name, "hightech"); if (have <= 0) return 0; const ratio = have / needed; store.hightech = 0; return points * ratio; }, // The science building(s) a city runs, named for a shortage notice. _cityResearchBuilding(city) { const names = []; for (const [indexStr, level] of Object.entries(city.buildings || {})) { if (level <= 0) continue; const proto = this.protoBuildings[Number(indexStr)]; if (!proto) continue; if ((proto.effects || []).some((e) => e.stat === EFFECT_RESEARCH)) names.push(proto.name); } return names.length > 0 ? names.join(" & ") : "Science buildings"; }, // ----------------------------------------------------- shortage notices -- // Records that a producing building in a region went without the material or // power it needs, for the day's notice to that nation's own viewer. Notes are // deduplicated per building, region and resource. _noteResourceShortage(civ, building, region, resource) { if (civ < 0 || !building || !region || !resource) return; let set = this._resourceAlerts.get(civ); if (!set) { set = new Map(); this._resourceAlerts.set(civ, set); } const key = `${region}|${building}|${resource}`; if (!set.has(key)) set.set(key, { building, region, resource }); }, // A grid short of power leaves its resource works idle: the owner is told // which work in which region is missing energy. _noteGridPowerShortages(ledger, components) { for (const [id, component] of ledger) { if (component.civ < 0 || component.industrialScale >= 0.999) continue; const city = this._componentCity(id, components); if (!city) continue; for (const [k, imprId] of this.tileImprovements) { if (components.get(k) !== id) continue; const proto = tileImprovementById(imprId); if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue; this._noteResourceShortage(component.civ, proto.name, city.name, "energy"); } } }, // The day's shortage notices for one nation, for its own viewer. Each carries // the day it was raised so the viewer re-announces it once per day. _resourceAlertsFor(civ) { const set = this._resourceAlerts ? this._resourceAlerts.get(civ) : null; if (!set) return []; const day = Math.floor(this.totalHours / HOURS_PER_DAY); return Array.from(set.values()).map((entry) => ({ ...entry, day })); }, };