The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days. Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates. Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
3016 lines
131 KiB
JavaScript
3016 lines
131 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 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,
|
|
PRICE_INDEX_WINDOW_DAYS,
|
|
MIDDLEMAN,
|
|
FAMINE,
|
|
tileImprovementById,
|
|
isResourceTileBuilding,
|
|
resourceById,
|
|
MONEY,
|
|
} from "../data.js";
|
|
import {
|
|
cityEnergyPerDay,
|
|
foodNeedPerDay,
|
|
unitFoodPerDay,
|
|
tileFoodOutput,
|
|
steelNeedPerDay,
|
|
luxuryNeedPerDay,
|
|
foodMonthlyTarget,
|
|
stockpileTarget,
|
|
producerEnergyPerDay,
|
|
foodSynthesisEnergy,
|
|
synthesisedFoodFor,
|
|
nextMarketPrice,
|
|
protoUpkeep,
|
|
constructionResourceCost as sharedConstructionResourceCost,
|
|
} from "../resources.js";
|
|
import { deliveryEnergyKwh, deliveryEnergyForResource, traverse } from "../economy_graph.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. High-tech is an industry like any
|
|
// other: a nation short of it places chip foundries just as it places mills and
|
|
// mines, and the power pass then builds the grid to run them. Fusion, locked
|
|
// behind research, 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;
|
|
// The steel and high-tech a city opens holding, on top of its people's own
|
|
// staples. Construction is settled in steel, so the opening store is sized to
|
|
// let a fresh region raise its first works before any producer comes online.
|
|
const OPENING_STEEL_STORE = 200_000;
|
|
const OPENING_HIGHTECH_STORE = 500;
|
|
|
|
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] });
|
|
}
|
|
// The world price of every commodity at the start of each month, oldest
|
|
// first, so the central bank can read month-on-month and year-on-year
|
|
// inflation. The first sample is the opening price, the index's base.
|
|
this.resourceMonthlyPrices = new Map();
|
|
for (const id of RESOURCE_IDS) {
|
|
this.resourceMonthlyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
|
|
}
|
|
// One world price per day, newest last, for the central bank's index chart.
|
|
// The series rolls: once it holds a full window the oldest day drops as a
|
|
// new one is sampled, so it never grows with the game. The chart is drawn
|
|
// against this fixed window, so days the game has not reached yet are left
|
|
// blank rather than stretched across the plot.
|
|
this.resourceDailyPrices = new Map();
|
|
for (const id of RESOURCE_IDS) {
|
|
this.resourceDailyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
|
|
}
|
|
this.resourceLinks = [];
|
|
this._resourceShortages = new Map();
|
|
// The viewer-keyed delivery graph is rebuilt when the resource day rolls
|
|
// over; the cache starts empty after a fresh configure.
|
|
this._resourceGraphCache = null;
|
|
// The food each region has bought to relay on to its neighbours, so only
|
|
// that stock carries the middleman margin when it is resold.
|
|
this._relayStock = new Map();
|
|
// The regions whose food store has run dry and cannot feed their people.
|
|
this._famine = new Set();
|
|
this._resourceVersion = 0;
|
|
// Bumped whenever the market listing could have changed, so the delta
|
|
// snapshot re-ships it only then. The hard tick moves it every day; a
|
|
// simple-economy trade bumps it itself.
|
|
this._marketVersion = 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 working store of steel and
|
|
// luxury, and some high-tech, so an early game with no producer yet can still
|
|
// build. Steel is stocked deeply because construction is paid in steel, not
|
|
// cash: the opening store is sized to cover a first building's material bill.
|
|
_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 + OPENING_STEEL_STORE;
|
|
stock.luxury = luxuryNeedPerDay(city.population) * 45;
|
|
stock.hightech = OPENING_HIGHTECH_STORE;
|
|
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 });
|
|
}
|
|
// The covering passes below seat every industry a nation is short of,
|
|
// high-tech included, so no resource gets a special hand-placed start.
|
|
for (const claim of claims) {
|
|
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
|
|
}
|
|
// The simple economy keys construction speed to a region's spare power, so
|
|
// every region must open with at least one plant. The seeding above only
|
|
// covers grids the converters put in deficit; this fills the gaps. The
|
|
// override is absent in the hard model, so the guard keeps hard untouched.
|
|
if (this.isSimpleEconomy() && this._seedSimpleRegionPlants) {
|
|
this._seedSimpleRegionPlants();
|
|
}
|
|
this._clearTileGdpCache();
|
|
},
|
|
|
|
// 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._clearTileGdpCache();
|
|
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);
|
|
// A nation with no high-tech works of its own is treated as short one chip
|
|
// foundry, so every power opens the game with a high-tech industry just as
|
|
// it opens with steel mills and mines. Once one stands, the real deficit
|
|
// takes over.
|
|
const hightechFloor = made.hightech > 0
|
|
? 0
|
|
: tileImprovementById("chip_foundry").outputPerDay / SEED_MATERIAL_MARGIN;
|
|
return {
|
|
steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
|
|
luxury: luxuryNeedPerDay(population) - made.luxury,
|
|
hightech: (upkeep.hightech || 0) - made.hightech + hightechFloor,
|
|
};
|
|
},
|
|
|
|
// 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;
|
|
},
|
|
|
|
// Moves every commodity's price a share of the way toward the equilibrium the
|
|
// day's world-wide supply and demand imply, and records the inflation the
|
|
// move adds up to. The simple economy overrides this to a no-op: its prices
|
|
// are derived from the global market stock, not from a day's imbalance.
|
|
_updateMarketPrices(market) {
|
|
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);
|
|
},
|
|
|
|
// 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;
|
|
},
|
|
|
|
// Snapshots every commodity's world price at the start of a new month. Called
|
|
// from the monthly rollover, so the series holds one opening price per month
|
|
// for the month-on-month and year-on-year inflation readings.
|
|
_sampleResourcePrices() {
|
|
if (!this.resourceMonthlyPrices) return;
|
|
for (const id of RESOURCE_IDS) {
|
|
const series = this.resourceMonthlyPrices.get(id) || [];
|
|
series.push(this.getResourcePrice(id));
|
|
this.resourceMonthlyPrices.set(id, series);
|
|
}
|
|
},
|
|
|
|
// Adds today's world prices to the daily series the central bank charts, and
|
|
// drops the oldest once the rolling window is full. Called at the end of every
|
|
// daily resource tick, after the day's prices have been set.
|
|
_sampleDailyResourcePrices() {
|
|
if (!this.resourceDailyPrices) return;
|
|
for (const id of RESOURCE_IDS) {
|
|
const series = this.resourceDailyPrices.get(id) || [];
|
|
series.push(this.getResourcePrice(id));
|
|
while (series.length > PRICE_INDEX_WINDOW_DAYS) series.shift();
|
|
this.resourceDailyPrices.set(id, series);
|
|
}
|
|
},
|
|
|
|
// Settles the world until its commodity prices are steady: it keeps advancing
|
|
// whole days until, over the trailing `window` days, no commodity has moved
|
|
// more than `tolerance` from where it stood at the start of that window. A
|
|
// quiet market is reached in far fewer days than a fixed settle would wait, so
|
|
// the game can open with its economy already established without the long
|
|
// fixed run. `maxDays` caps the settle so an unsettled world cannot hang the
|
|
// server; a price series too short to judge simply keeps the economy running.
|
|
// Returns the number of days actually settled.
|
|
warmUpToStability({ window = 7, tolerance = 0.1, maxDays = 150 } = {}) {
|
|
const cap = Math.max(0, Math.floor(maxDays));
|
|
if (cap <= 0) return 0;
|
|
let day = 0;
|
|
try {
|
|
while (day < cap) {
|
|
for (let hour = 0; hour < HOURS_PER_DAY; hour++) this.advanceHour();
|
|
day += 1;
|
|
if (day >= window && this.pricesAreSteady(window, tolerance)) break;
|
|
}
|
|
} finally {
|
|
this._resetSettleCaches();
|
|
}
|
|
return day;
|
|
},
|
|
|
|
// Whether every commodity's world price has held within `tolerance` of its
|
|
// price `window` days ago. A series shorter than the window cannot be judged,
|
|
// so it reads as not yet steady.
|
|
pricesAreSteady(window = 7, tolerance = 0.1) {
|
|
if (!this.resourceDailyPrices) return true;
|
|
for (const id of RESOURCE_IDS) {
|
|
const series = this.resourceDailyPrices.get(id) || [];
|
|
if (series.length <= window) return false;
|
|
const before = series[series.length - 1 - window];
|
|
const now = series[series.length - 1];
|
|
if (!(before > 0)) continue;
|
|
if (Math.abs(now / before - 1) > tolerance) return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
// A commodity's price index and its month-on-month and year-on-year inflation,
|
|
// from the monthly series. The index is 100 at the opening price in January
|
|
// 2000. Monthly compares with last month's opening, yearly with the same month
|
|
// a year earlier; through 2000 there is no earlier year, so the year's first
|
|
// month stands in and yearly reads as growth since the start of the game.
|
|
resourceInflation(id) {
|
|
const series = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) : null;
|
|
const price = this.getResourcePrice(id);
|
|
if (!series || series.length === 0) {
|
|
return { index: 100, monthly: 0, yearly: 0 };
|
|
}
|
|
const base = series[0] || price;
|
|
const index = base > 0 ? (price / base) * 100 : 100;
|
|
// No previous month before February 2000, so monthly reads zero there.
|
|
const previous = series.length >= 2 ? series[series.length - 2] : null;
|
|
const yearAgo = series.length >= 13 ? series[series.length - 13] : base;
|
|
return {
|
|
index,
|
|
monthly: previous > 0 ? price / previous - 1 : 0,
|
|
yearly: yearAgo > 0 ? price / yearAgo - 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, the private sector's
|
|
// daily consumption, and a per-building breakdown of who demanded what. The
|
|
// breakdown is inverted by resource -- `demand[id]` is a list of
|
|
// `{ name, coords, amount }` consumers -- so the panel can expand any
|
|
// resource into the buildings that drew it.
|
|
getRegionResourceInfo(city) {
|
|
const economy = this.getCityEconomy(city);
|
|
const population = economy.population;
|
|
const store = this.getCityResourceStock(city);
|
|
|
|
const consumers = [];
|
|
const addConsumer = (name, coords, consume) => {
|
|
consumers.push({ name, coords, consume });
|
|
};
|
|
|
|
addConsumer("People", city.coords, {
|
|
food: foodNeedPerDay(population),
|
|
steel: steelNeedPerDay(population),
|
|
luxury: luxuryNeedPerDay(population),
|
|
});
|
|
// The region's baseline electric appetite, from its yearly GDP.
|
|
const baseline = cityEnergyPerDay(economy.gdp);
|
|
if (baseline > 0) addConsumer("Region grid", city.coords, { energy: baseline });
|
|
|
|
// Every building raised in the city wears out its daily materials.
|
|
for (const [index, level] of Object.entries(city.buildings || {})) {
|
|
if (level <= 0) continue;
|
|
const proto = this.protoBuildings[Number(index)];
|
|
if (!proto) continue;
|
|
const up = this.buildingUpkeepResources(proto, level);
|
|
addConsumer(proto.name, city.coords, {
|
|
steel: up.steel || 0,
|
|
energy: up.energy || 0,
|
|
hightech: up.hightech || 0,
|
|
});
|
|
}
|
|
|
|
// Every production building standing in this region draws power and wears
|
|
// its own materials, wherever it sits.
|
|
for (const [k, id] of this.tileImprovements) {
|
|
if (this.tileImprovementOwner.get(k) !== city.civ) continue;
|
|
const proto = tileImprovementById(id);
|
|
if (!isResourceTileBuilding(proto)) continue;
|
|
const coords = parseKey(k);
|
|
if (this.regionCityAt(coords) !== city) continue;
|
|
const up = protoUpkeep(proto, proto.buildCost || 0);
|
|
const energy = this.resourceBuildingInputAt(k, proto, "energyPerDay") +
|
|
this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
|
|
addConsumer(proto.name, coords, {
|
|
steel: up.steel || 0,
|
|
hightech: up.hightech || 0,
|
|
energy: energy + (up.energy || 0),
|
|
});
|
|
}
|
|
|
|
const consumption = { food: 0, steel: 0, luxury: 0, hightech: 0, energy: 0 };
|
|
const demand = {};
|
|
for (const id of RESOURCE_IDS) demand[id] = [];
|
|
for (const consumer of consumers) {
|
|
for (const id of RESOURCE_IDS) {
|
|
const amount = consumer.consume[id] || 0;
|
|
if (!(amount > 0)) continue;
|
|
consumption[id] += amount;
|
|
demand[id].push({
|
|
name: consumer.name,
|
|
coords: [consumer.coords.x, consumer.coords.y],
|
|
amount,
|
|
});
|
|
}
|
|
}
|
|
|
|
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, demand };
|
|
},
|
|
|
|
// 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) {
|
|
return this.getCivUpkeepTotals(civ);
|
|
},
|
|
|
|
// The nation's daily wear summed without building the per-category labels,
|
|
// part lists and merged sources the budget panel needs. The summary and the
|
|
// HUD upkeep read this several times per broadcast, so they skip that
|
|
// bookkeeping rather than allocate a breakdown just to add four numbers.
|
|
getCivUpkeepTotals(civ) {
|
|
const total = { steel: 0, energy: 0, hightech: 0, food: 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) continue;
|
|
const costs = this.buildingUpkeepResources(proto, level);
|
|
total.steel += costs.steel || 0;
|
|
total.energy += costs.energy || 0;
|
|
total.hightech += costs.hightech || 0;
|
|
}
|
|
}
|
|
const battle = this._battleUnitIds();
|
|
for (const unit of this.units) {
|
|
if (unit.civ !== civ) continue;
|
|
const proto = this.unitProto(unit);
|
|
if (!proto) continue;
|
|
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
|
const multiplier = this._statusUpkeepMultiplier(statuses);
|
|
const costs = this.unitUpkeepResources(proto);
|
|
total.steel += (costs.steel || 0) * multiplier;
|
|
total.hightech += (costs.hightech || 0) * multiplier;
|
|
}
|
|
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);
|
|
total.steel += costs.steel || 0;
|
|
total.energy += (costs.energy || 0) + (proto.fuelEnergyPerDay || 0);
|
|
total.hightech += costs.hightech || 0;
|
|
}
|
|
const counts = this._transportTiles(civ);
|
|
total.steel += counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
|
|
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
|
|
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;
|
|
},
|
|
|
|
// A tile improvement that was raised with money to spare keeps that reserve
|
|
// for its own upkeep. Each day, before the state is billed, every work with a
|
|
// reserve covers as much of its own steel and high-tech wear as the money
|
|
// reaches; the reserve is spent and the nation's `works` and `transport`
|
|
// demand is cut by what it covered, so a well funded work spares the treasury
|
|
// until it runs dry. The spent money is paid into the nation's regions, since
|
|
// it is the private sector the state would otherwise have paid.
|
|
//
|
|
// A private producer -- a mine, a mill or a plant -- is the exception: it is
|
|
// its own business, so it pays the whole day's wear from the cash its output
|
|
// earned and borrows from the central bank when that cash falls short. The
|
|
// state never subsidises a producer, so its wear never reaches the treasury
|
|
// bill.
|
|
_applyWorksMaintenance(civ, categories) {
|
|
if (!categories) return;
|
|
const steelUnit = this._marketUnitCost("steel");
|
|
const techUnit = this._marketUnitCost("hightech");
|
|
let covered = 0;
|
|
// Spend one work's reserve against its own steel/high-tech wear, trimming
|
|
// the matching budget category and its per-label source by whatever it
|
|
// covered. `full` bills the owner for the whole day whatever its reserve
|
|
// (a private producer, which pays from its cash and borrows what is short).
|
|
const apply = (reserve, spend, steel, hightech, label, category, full = false) => {
|
|
const value = steel * steelUnit + hightech * techUnit;
|
|
if (!(value > 0)) return;
|
|
const fraction = full ? 1 : (reserve > 0 ? Math.min(1, reserve / value) : 0);
|
|
if (!(fraction > 0)) return;
|
|
const spent = value * fraction;
|
|
covered += spent;
|
|
spend(spent);
|
|
if (category) {
|
|
category.steel = Math.max(0, (category.steel || 0) - steel * fraction);
|
|
category.hightech = Math.max(0, (category.hightech || 0) - hightech * fraction);
|
|
const source = (category.sources || []).find((entry) => entry.label === label);
|
|
if (source) {
|
|
source.steel = Math.max(0, (source.steel || 0) - steel * fraction);
|
|
source.hightech = Math.max(0, (source.hightech || 0) - hightech * fraction);
|
|
}
|
|
}
|
|
};
|
|
// Pay a reserve from the tile's own improvement cash, which never overdraws.
|
|
const spendReserve = (k) => (spent) => {
|
|
const after = (this.improvementCash.get(k) || 0) - spent;
|
|
if (after > 0) this.improvementCash.set(k, after);
|
|
else this.improvementCash.delete(k);
|
|
};
|
|
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);
|
|
const steel = costs.steel || 0;
|
|
const hightech = costs.hightech || 0;
|
|
if (this._isProductionAgent(proto)) {
|
|
apply(0, (spent) => this._chargeBuildingAccount(k, spent),
|
|
steel, hightech, proto.name, categories.works, true);
|
|
} else {
|
|
apply(this.improvementCash.get(k) || 0, spendReserve(k),
|
|
steel, hightech, proto.name, categories.works);
|
|
}
|
|
}
|
|
for (const [tiles, id, label] of [
|
|
[this.roads, "road", "Roads"],
|
|
[this.railways, "railway", "Railways"],
|
|
]) {
|
|
const steel = RESOURCE_RULES.transportUpkeepSteel[id] || 0;
|
|
for (const k of tiles) {
|
|
if (this.civAt(parseKey(k)) !== civ) continue;
|
|
apply(this.improvementCash.get(k) || 0, spendReserve(k),
|
|
steel, 0, label, categories.transport);
|
|
}
|
|
}
|
|
// The reserves paid the private sector the state would otherwise have paid.
|
|
this._creditRegions(civ, covered);
|
|
},
|
|
|
|
// 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. The worn
|
|
// quantity travels with the cash, so each row can show the price it paid.
|
|
const consumedSteel = drawnSteel + gotSteel;
|
|
const consumedTech = drawnTech + gotTech;
|
|
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) {
|
|
const qty = totalSteel > 0 ? consumedSteel * ((demand.steel || 0) / totalSteel) : 0;
|
|
this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel", qty);
|
|
}
|
|
if (techCash > 0) {
|
|
const qty = totalTech > 0 ? consumedTech * ((demand.hightech || 0) / totalTech) : 0;
|
|
this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech", qty);
|
|
}
|
|
}
|
|
}
|
|
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. `quantity` is
|
|
// how much of the resource the cash bought, split the same way, so the budget
|
|
// row can show the average price paid per unit.
|
|
_recordResourceBySource(civ, category, resource, cash, sources, field, quantity = 0) {
|
|
let total = 0;
|
|
for (const source of sources) total += source[field] || 0;
|
|
if (!(total > 0)) {
|
|
this._recordBudgetCategoryResource(civ, category, resource, cash, null, quantity);
|
|
return;
|
|
}
|
|
let remaining = cash;
|
|
let remainingQty = quantity;
|
|
for (let i = 0; i < sources.length; i++) {
|
|
const source = sources[i];
|
|
const last = i === sources.length - 1;
|
|
const share = (source[field] || 0) / total;
|
|
const amount = last ? remaining : cash * share;
|
|
const qty = last ? remainingQty : quantity * share;
|
|
remaining -= amount;
|
|
remainingQty -= qty;
|
|
if (amount > 0) {
|
|
this._recordBudgetCategoryResource(civ, category, resource, amount, source.label, qty);
|
|
}
|
|
}
|
|
},
|
|
|
|
// ------------------------------------------------------ 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 cache = this._marketAccessCache;
|
|
const cacheKey = `city:${city.id}`;
|
|
if (cache && cache.has(cacheKey)) return cache.get(cacheKey);
|
|
const continent = this._continentOf(city.coords);
|
|
let result = false;
|
|
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)) { result = true; break; }
|
|
}
|
|
if (cache) cache.set(cacheKey, result);
|
|
return result;
|
|
},
|
|
|
|
_civMarketAccess(civ) {
|
|
const cache = this._marketAccessCache;
|
|
const cacheKey = `civ:${civ}`;
|
|
if (cache && cache.has(cacheKey)) return cache.get(cacheKey);
|
|
let result = false;
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
if (this._cityMarketAccess(city)) { result = true; break; }
|
|
}
|
|
if (cache) cache.set(cacheKey, result);
|
|
return result;
|
|
},
|
|
|
|
// The grid power a region's land harvest draws, after its food-efficiency
|
|
// research has cut the energy each tonne costs.
|
|
_foodHarvestEnergy(civ, produced) {
|
|
return producerEnergyPerDay("food", produced) /
|
|
(1 + this.resourceEfficiency(civ, "food"));
|
|
},
|
|
|
|
// ------------------------------------------------------------ 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();
|
|
// The food that actually reached each region's store today, split by where
|
|
// it came from. The potential harvest is known before the grid throttles it,
|
|
// so every view reads this map rather than recomputing a crop that a power
|
|
// shortfall never grew.
|
|
this._foodProduced = new Map();
|
|
this._resetTaxLedger();
|
|
// The simple economy has no trade to tax: its one revenue is the day's
|
|
// per-inhabitant approval tax, collected into the fresh ledger. The override
|
|
// is absent in the hard model, so the guard keeps hard untouched.
|
|
if (this.isSimpleEconomy() && this._tickApprovalTaxes) this._tickApprovalTaxes();
|
|
// The transport network cannot move while the day's trades run, so the many
|
|
// sourcing passes below share one set of graph walks and port-access checks.
|
|
// The walks themselves outlive the day (see `_tradeGraph`); only the
|
|
// port-access memo is rebuilt for the run.
|
|
this._marketAccessCache = new Map();
|
|
// The people do not move during the day's trading, so each city's population
|
|
// is read once for every surplus check instead of once per check.
|
|
this._nodePopulationCache = 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();
|
|
// 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,
|
|
source: "harvest",
|
|
});
|
|
const component = ledger.get(components.get(k));
|
|
if (component) {
|
|
const foodPower = this._foodHarvestEnergy(component.civ, 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. A region
|
|
// whose store sits below its reserve keeps growing past the day's meal
|
|
// toward that reserve while the marginal energy stays under the food price,
|
|
// so a war that empties the pantry is refilled from the grid, not by famine.
|
|
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 store = this.getCityResourceStock(city).food || 0;
|
|
const reserveGap = Math.max(0, stockpileTarget(need) - store);
|
|
// What the region would like to grow: the meal it cannot cover from the
|
|
// land, plus what it must add to refill the reserve to a month.
|
|
const shortfall = Math.max(0, need + reserveGap - produced);
|
|
// What it must grow whatever the price: the ration its land cannot grow.
|
|
const mustGrow = Math.max(0, 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");
|
|
}
|
|
// Food efficiency cheapens synthesis, so it is worth growing more before
|
|
// the market wins; the energy it actually spends falls by the same share.
|
|
const foodEfficiency = 1 + this.resourceEfficiency(city.civ, "food");
|
|
const extra = synthesisedFoodFor(
|
|
foodPrice, energyPrice / foodEfficiency, shortfall, buyable, mustGrow
|
|
);
|
|
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) / foodEfficiency;
|
|
component.foodEnergy += energy;
|
|
component.foodConsumers.push({ city, amount: energy });
|
|
pendingFood.push({
|
|
cityId: city.id,
|
|
coords: city.coords,
|
|
component: componentId,
|
|
amount: extra,
|
|
source: "synthesis",
|
|
});
|
|
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;
|
|
// The harvest is served first: it is a sliver of the grid next to the
|
|
// cities' own draw, but a shortfall is a famine while an under-served city
|
|
// only dims. The cities come next, then the converters: an idle factory is
|
|
// the least bad loss of the three.
|
|
const foodServed = Math.min(component.foodEnergy, component.available);
|
|
component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1;
|
|
const afterFood = Math.max(0, component.available - foodServed);
|
|
const cityServed = Math.min(component.cityEnergy, afterFood);
|
|
component.cityShortage = component.cityEnergy > 0
|
|
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
|
|
: 0;
|
|
const afterCity = afterFood - cityServed;
|
|
component.industrialScale = component.converterEnergy > 0
|
|
? Math.max(0, Math.min(1, afterCity / component.converterEnergy))
|
|
: 1;
|
|
market.get("energy").supply += component.supply;
|
|
market.get("energy").demand += component.demand;
|
|
}
|
|
// Kept for the view (and the economy simulator's grid panel): what the day's
|
|
// power went to and how much the grid throttled each kind of load.
|
|
this._energyLedger = ledger;
|
|
// 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.
|
|
const grownFood = new Map();
|
|
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;
|
|
grownFood.set(entry.cityId, (grownFood.get(entry.cityId) || 0) + produced);
|
|
const slot = this._foodProduced.get(entry.cityId) || { harvest: 0, synthesis: 0 };
|
|
slot[entry.source] += produced;
|
|
this._foodProduced.set(entry.cityId, slot);
|
|
}
|
|
|
|
// Middlemen top up for neighbours whose own power-limited harvest fell short
|
|
// before anyone buys, so the relay stock is on hand in the same day.
|
|
this._relayFood(grownFood, nodes);
|
|
|
|
// 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. The storage nodes are
|
|
// re-read now that the day's production has filled the producers' stores:
|
|
// the list built before production is missing every building whose store it
|
|
// created this day, and the trade graph caches under a stamp that already
|
|
// reflects the larger set, so the new works would stay hidden from buyers.
|
|
this._consumeCityResources(components, market, this._resourceNodes());
|
|
|
|
// The army eats too, and its rations are drawn from the same stores after
|
|
// the prices are set. Count the appetite now, so the market's food price
|
|
// weighs the soldiers as well as the civilians.
|
|
market.get("food").demand += this._militaryFoodNeed();
|
|
|
|
// 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);
|
|
// Works that finished with money to spare keep it against their own wear;
|
|
// that reserve is spent before the state is asked to pay.
|
|
this._applyWorksMaintenance(civ, categories);
|
|
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. The simple economy
|
|
// replaces this with a no-op, so its market listing only re-ships when a
|
|
// trade moves the stock.
|
|
this._marketVersion += 1;
|
|
this._updateMarketPrices(market);
|
|
// Today's prices join the central bank's daily index series.
|
|
this._sampleDailyResourcePrices();
|
|
|
|
this._applyResourceShortages(ledger);
|
|
this._pruneResourceStock();
|
|
this._marketAccessCache = null;
|
|
this._nodePopulationCache = null;
|
|
// The trade-graph cache is deliberately *not* dropped here: it is kept until
|
|
// a road, railway, storage work or territory change bumps the network stamp,
|
|
// or a wartime unit move clears it. See `_tradeGraph`.
|
|
},
|
|
|
|
// 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)) {
|
|
// Efficiency research cuts the power each commodity costs: the draw is
|
|
// divided by `1 + bonus`, so the same output needs less energy.
|
|
const efficiency = 1 + this.resourceEfficiency(component.civ, proto.resource);
|
|
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 / efficiency;
|
|
component.converterEnergy += amount;
|
|
component.converters.push({ k, amount });
|
|
}
|
|
if (proto.fuelEnergyPerDay) {
|
|
const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay") / efficiency;
|
|
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 tiles held by hostile troops: an army camped on a road or a fleet in a
|
|
// sea lane severs the route while it stands there. Trade, grid power and unit
|
|
// supply searches all route around these tiles, so moving a unit in forces
|
|
// the next recalculation to find another way -- or none.
|
|
_hostileOccupied(civ) {
|
|
const cache = this._hostileOccupiedCache;
|
|
if (cache && cache.has(civ)) return cache.get(civ);
|
|
const blocked = new Set();
|
|
for (const unit of this.units) {
|
|
if (unit.civ === civ) continue;
|
|
if (!this.isAtWar(civ, unit.civ)) continue;
|
|
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
|
blocked.add(key(unit.coords.x, unit.coords.y));
|
|
}
|
|
if (cache) cache.set(civ, blocked);
|
|
return blocked;
|
|
},
|
|
|
|
// 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 blocked = this._hostileOccupied(city.civ);
|
|
const visited = new Set([origin]);
|
|
const queue = [{ coords: city.coords, energy: 0 }];
|
|
let head = 0;
|
|
while (head < queue.length) {
|
|
const current = queue[head++];
|
|
for (const neighbour of this._neighbours(current.coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (visited.has(nk) || blocked.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;
|
|
},
|
|
|
|
// Middlemen: a region that can reach a food surplus it does not itself need
|
|
// buys extra to resell to nearby regions that have no reach of their own. It
|
|
// charges a margin on that resale, which covers its transport and turns a
|
|
// profit, so food hops past a region that is itself a net importer.
|
|
_relayFood(grownFood, nodes) {
|
|
const need = new Map();
|
|
const stock = new Map();
|
|
for (const city of this.cities) {
|
|
need.set(city.id, foodNeedPerDay(this.getCityEconomy(city).population));
|
|
stock.set(city.id, this.getCityResourceStock(city).food || 0);
|
|
}
|
|
// What each region actually grew today, and whether it can reach any food
|
|
// surplus on its own.
|
|
const sources = new Map();
|
|
const allowSea = new Map();
|
|
for (const city of this.cities) {
|
|
const sea = this._cityMarketAccess(city) && !this._isEncircled(city.coords, city.civ);
|
|
allowSea.set(city.id, sea);
|
|
sources.set(city.id, this._nearbySuppliers(city, "food", nodes, sea));
|
|
}
|
|
for (const city of this.cities) {
|
|
const suppliers = sources.get(city.id);
|
|
if (!suppliers || suppliers.length === 0) continue;
|
|
let relayDemand = 0;
|
|
for (const other of this.cities) {
|
|
if (other.id === city.id) continue;
|
|
if ((sources.get(other.id) || []).length > 0) continue;
|
|
const shortfall = Math.max(0, need.get(other.id) - (grownFood.get(other.id) || 0));
|
|
if (shortfall <= 0) continue;
|
|
if (stock.get(other.id) >= need.get(other.id) * 2) continue;
|
|
// The buyer must be able to reach this region's store.
|
|
if (this.topology.tileDistance(city.coords, other.coords) > RESOURCE_TRADE_RADIUS) {
|
|
continue;
|
|
}
|
|
relayDemand += shortfall;
|
|
}
|
|
if (!(relayDemand > 0)) continue;
|
|
const wanted = relayDemand * MIDDLEMAN.coverDays;
|
|
const store = this.getCityResourceStock(city);
|
|
const bought = this._procureFromNeighbours(city, "food", wanted, store, nodes, {
|
|
allowSea: allowSea.get(city.id),
|
|
});
|
|
if (bought > 0) {
|
|
this._relayStock.set(city.id, (this._relayStock.get(city.id) || 0) + bought);
|
|
}
|
|
}
|
|
},
|
|
|
|
_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();
|
|
// Starving regions buy first: a city topping up its month-long buffer must
|
|
// not strip a neighbour whose store is empty of the last reachable surplus.
|
|
const urgency = (city) => {
|
|
const store = this.getCityResourceStock(city);
|
|
const need = Math.max(1e-9, foodNeedPerDay(this.getCityEconomy(city).population));
|
|
return (store.food || 0) / need;
|
|
};
|
|
const order = this.cities.slice().sort((a, b) => urgency(a) - urgency(b));
|
|
// High-tech has no per-person appetite: the nation's buildings, units and
|
|
// works wear it out and the draw falls on the regions. A region sizes its
|
|
// reserve to its population share of that national burn.
|
|
const techPerCapita = new Map();
|
|
for (const city of this.cities) {
|
|
if (techPerCapita.has(city.civ)) continue;
|
|
const burn = Math.max(0, this.getCivUpkeepResources(city.civ).hightech || 0);
|
|
const people = Math.max(1e-9, this.getPlayerPopulation(city.civ));
|
|
techPerCapita.set(city.civ, burn / people);
|
|
}
|
|
for (const city of order) {
|
|
const population = this.getCityEconomy(city).population;
|
|
const needs = {
|
|
food: foodNeedPerDay(population),
|
|
steel: steelNeedPerDay(population),
|
|
luxury: luxuryNeedPerDay(population),
|
|
};
|
|
const store = this.getCityResourceStock(city);
|
|
const shortage = {};
|
|
// 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;
|
|
// A pantry below its month is a standing bid: the region wants to
|
|
// refill the reserve, so even a day the meal is covered prices food up
|
|
// while the store is thin. Spread over the reserve horizon, so an empty
|
|
// store bids at most a second helping of the daily need.
|
|
if (id === "food") {
|
|
const reserveGap = Math.max(0, stockpileTarget(need) - store[id]);
|
|
market.get("food").demand += reserveGap / RESOURCE_RULES.stockpileDays;
|
|
}
|
|
// Every material is stocked ahead: the region tops its store up to a
|
|
// month of the coming needs, not just today's.
|
|
const wanted = Math.max(dailyShortfall, stockpileTarget(need) - store[id]);
|
|
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; the 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;
|
|
// The food a region could not buy is counted again as demand, so a
|
|
// famine bids the national price up rather than passing unseen.
|
|
if (id === "food") {
|
|
market.get("food").demand += (dailyShortfall - bought) * RESOURCE_RULES.famineBidFactor;
|
|
}
|
|
}
|
|
}
|
|
// High-tech is not eaten here -- the state draws it later for upkeep and
|
|
// research -- so the region only tops the store up to its month's reserve.
|
|
const techNeed = (techPerCapita.get(city.civ) || 0) * population;
|
|
market.get("hightech").demand += techNeed;
|
|
const techWanted = stockpileTarget(techNeed) - (store.hightech || 0);
|
|
if (techWanted > 0 && !cutOff) {
|
|
this._procureFromNeighbours(city, "hightech", techWanted, store, nodes, { allowSea });
|
|
}
|
|
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, maxUnitCost = Infinity } = 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;
|
|
// A buyer with a locked price refuses a supplier quoting above it.
|
|
if (goods + freight > maxUnitCost) continue;
|
|
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;
|
|
// A region selling food it bought to relay charges its middleman margin on
|
|
// that stock, so the resale covers its transport and turns a profit.
|
|
const markup = this._middlemanMarkup(supplier, id, bought, price);
|
|
const value = (goods + freight) * bought + markup;
|
|
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. A free-trade treaty
|
|
// between the two governments lifts it.
|
|
const exemption = this._tariffExempt(supplier.civ, city.civ);
|
|
const exportTax = exemption ? 0 : value * this.getTaxRate(supplier.civ, "export", id);
|
|
// The central bank pays part of the bill from its reserves, so a region
|
|
// or converter imports food and energy more cheaply than the market.
|
|
const subsidy = this._centralBankImportSubsidy(city.civ, id, value + exportTax);
|
|
const acquired = this._regionAcquireCurrency(
|
|
city, supplier.civ, value + exportTax, charge, !payer, subsidy
|
|
);
|
|
if (!acquired) continue;
|
|
if (acquired.subsidised > 0) this._recordImportSubsidy(city.civ, acquired.subsidised);
|
|
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._tariffExempt(supplier.civ, city.civ)
|
|
? 0
|
|
: 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, with
|
|
// land capped at `RESOURCE_TRADE_RADIUS` tiles and sea lanes running any
|
|
// distance. `routeEnergyPerTonne` is the accumulated kWh to move one tonne
|
|
// from the city to that node.
|
|
_nearbySuppliers(city, id, nodes, allowSea = true) {
|
|
return this._tradeReachable(city, nodes, allowSea, (node) => this._nodeSurplus(node, id) > 0, id);
|
|
},
|
|
|
|
// The storage nodes a city can haul from over the transport network: a
|
|
// breadth-first spread over land and sea that hands every reachable node to
|
|
// `accept`. Land legs are capped at `RESOURCE_TRADE_RADIUS` tiles, but a
|
|
// shipping lane between ports carries goods any distance, so only land counts
|
|
// against the cap. `resource` only names the commodity charged for the haul;
|
|
// the transport rules themselves are commodity-independent, so the delivery
|
|
// graph passes one to price its rides. The traversal itself lives in the
|
|
// economy-graph toolkit; this method only describes the network it walks.
|
|
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
|
|
const list = this._tradeGraph(city, nodes, allowSea);
|
|
const mass = resourceById(resource)?.tonnesPerUnit || 1;
|
|
const scaled = mass === 1
|
|
? list
|
|
: list.map((node) => ({ ...node, routeEnergyPerTonne: node.routeEnergyPerTonne * mass }));
|
|
return accept ? scaled.filter((node) => accept(node)) : scaled;
|
|
},
|
|
|
|
// The full set of storage nodes a city can haul from over the transport
|
|
// network, with the least per-tonne energy to each. Independent of the day's
|
|
// changing surpluses, so it is computed once per (city, mode) and reused by
|
|
// every sourcing pass -- and across days -- until the network changes. The
|
|
// graph is also commodity-independent -- every commodity rides the same roads,
|
|
// and only its mass per unit scales the energy -- so the caller folds the
|
|
// commodity's mass in afterwards. The stamp below mixes the counters that move
|
|
// when a road, railway or territory is added or removed or redrawn with the
|
|
// size of the storage-node set, so even a direct edit picks a fresh stamp and
|
|
// rebuilds lazily; it is all O(1) reads. It deliberately leaves out the tile
|
|
// improvements and their version: a road, railway or storage work moves the
|
|
// edges or the nodes, but rebuilding a mine, mill or plant in place -- which
|
|
// bumps `_tileImprovementVersion` many times a day -- cannot change a single
|
|
// transport route, and treating it as one threw every city's cached walk away.
|
|
_tradeGraph(city, nodes, allowSea) {
|
|
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}` +
|
|
`:${this._territoryVersion}:${this.resourceStock ? this.resourceStock.size : 0}`;
|
|
if (!this._tradeGraphCache || this._tradeGraphStamp !== stamp) {
|
|
this._tradeGraphCache = new Map();
|
|
this._tradeGraphStamp = stamp;
|
|
}
|
|
const cache = this._tradeGraphCache;
|
|
const cacheKey = `${city.id}|${allowSea ? 1 : 0}`;
|
|
const hit = cache.get(cacheKey);
|
|
if (hit) return hit;
|
|
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 blocked = this._hostileOccupied(city.civ);
|
|
const reached = traverse(city.coords, {
|
|
id: (coords) => key(coords.x, coords.y),
|
|
maxWeight: RESOURCE_TRADE_RADIUS,
|
|
neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, blocked),
|
|
accept: (coords) => {
|
|
const here = byKey.get(key(coords.x, coords.y));
|
|
if (!here || key(here.coords.x, here.coords.y) === origin) return false;
|
|
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
|
|
return !hostile;
|
|
},
|
|
});
|
|
const list = reached.map((entry) => ({
|
|
...byKey.get(key(entry.node.x, entry.node.y)),
|
|
tiles: entry.distance,
|
|
routeEnergyPerTonne: entry.cost,
|
|
}));
|
|
cache.set(cacheKey, list);
|
|
return list;
|
|
},
|
|
|
|
// The passable outward edges of one tile for the trade network: land legs
|
|
// must run on a road or railway, sea legs cross open water and need a working
|
|
// port, and goods never cross the land of a nation you are at war with. Each
|
|
// edge is priced in the transport energy one tonne burns over it; the caller
|
|
// scales by the commodity's mass, so every commodity shares one graph.
|
|
_tradeNeighbours(city, coords, allowSea, blocked) {
|
|
const edges = [];
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (blocked.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;
|
|
}
|
|
edges.push({
|
|
node: neighbour,
|
|
cost: deliveryEnergyKwh(1, 1, mode, tile.movementCostMultiplier || 1),
|
|
// Only land legs are counted against the trade radius: a shipping lane
|
|
// runs as far as the coast does.
|
|
weight: sea ? 0 : 1,
|
|
});
|
|
}
|
|
return edges;
|
|
},
|
|
|
|
// Every supplier-to-city route among a nation's own storage nodes, whether or
|
|
// not a delivery ran along it. The trade graph draws these idle edges grey and
|
|
// lays the active legs over them. One BFS per city, so callers cache it.
|
|
_resourceCandidateEdges(civ) {
|
|
const nodes = this._resourceNodes();
|
|
const ownKeys = new Set();
|
|
for (const node of nodes) {
|
|
if (node.civ === civ) ownKeys.add(key(node.coords.x, node.coords.y));
|
|
}
|
|
const edges = new Map();
|
|
for (const city of this.cities) {
|
|
if (city.civ !== civ) continue;
|
|
const reachable = this._tradeReachable(
|
|
city, nodes, this._cityMarketAccess(city),
|
|
(node) => ownKeys.has(key(node.coords.x, node.coords.y))
|
|
);
|
|
for (const node of reachable) {
|
|
const from = key(node.coords.x, node.coords.y);
|
|
const to = key(city.coords.x, city.coords.y);
|
|
if (!edges.has(`${from}->${to}`)) {
|
|
edges.set(`${from}->${to}`, [node.coords.x, node.coords.y, city.coords.x, city.coords.y]);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(edges.values());
|
|
},
|
|
|
|
// The middleman margin a region charges when it sells food it bought to relay.
|
|
// Only the stock it actually bought for resale carries the margin, and the
|
|
// stock is drawn down as it sells.
|
|
_middlemanMarkup(supplier, id, bought, price) {
|
|
if (id !== "food" || !this._relayStock || supplier.kind !== "city") return 0;
|
|
const cityId = supplier.city.id;
|
|
const held = this._relayStock.get(cityId) || 0;
|
|
if (!(held > 0)) return 0;
|
|
const sold = Math.min(held, bought);
|
|
this._relayStock.set(cityId, held - sold);
|
|
return sold * price * MIDDLEMAN.markup;
|
|
},
|
|
|
|
_nodeSurplus(node, id) {
|
|
if (id === "hightech") return Math.max(0, node.stock.hightech || 0);
|
|
const population = node.city ? this._nodeCityPopulation(node.city) : 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 city's population for the day's surplus checks. The region walk behind
|
|
// `getCityEconomy` is expensive and a sourcing pass asks about the same city
|
|
// many times; the people do not move until the famine deaths at the end of
|
|
// the tick, so one figure per city is read for the whole run. The memo only
|
|
// exists during `_tickResources`; every other caller recomputes it.
|
|
_nodeCityPopulation(city) {
|
|
const cache = this._nodePopulationCache;
|
|
if (cache && cache.has(city.id)) return cache.get(city.id);
|
|
const population = this.getCityPopulation(city);
|
|
if (cache) cache.set(city.id, population);
|
|
return population;
|
|
},
|
|
|
|
// 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._clearTileGdpCache();
|
|
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 blocked = this._hostileOccupied(civ);
|
|
const visited = new Set([key(unit.coords.x, unit.coords.y)]);
|
|
const queue = [unit.coords];
|
|
let head = 0;
|
|
let marketAccess = null;
|
|
while (head < queue.length) {
|
|
const coords = queue[head++];
|
|
for (const neighbour of this._neighbours(coords)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (visited.has(nk) || blocked.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 (marketAccess === null) marketAccess = this._civMarketAccess(civ);
|
|
if (!marketAccess) 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);
|
|
const proto = this.unitProto(unit);
|
|
this._recordProvisionSpend(unit.civ, takeValue, procuredSpent, tonnes, proto ? proto.name : null);
|
|
return true;
|
|
},
|
|
|
|
// Books the food a unit ate, whether it came from a region's store or from a
|
|
// foreign one. `quantity` is the ration in tonnes and `source` the unit type,
|
|
// so the budget row can show what was eaten and the average price paid.
|
|
_recordProvisionSpend(civ, takeValue, procuredSpent, quantity = 0, source = null) {
|
|
const spent = takeValue + procuredSpent;
|
|
if (!(spent > 0)) return;
|
|
this._recordBudgetCash(civ, "units", -spent);
|
|
this._recordBudgetCategoryResource(civ, "units", "food", spent, source, quantity);
|
|
this._recordResourceSpend(civ, "upkeep", spent, "food");
|
|
},
|
|
|
|
// The day's ration multiplier for one land unit: a unit resting at home heals
|
|
// and eats more, and a unit in combat eats more too.
|
|
_unitFoodMultiplier(unit, battle) {
|
|
const here = this.cityAt(unit.coords);
|
|
const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp);
|
|
if (resting) return RESOURCE_RULES.infantryRestingFoodMultiplier;
|
|
return battle.has(unit.id) ? RESOURCE_RULES.infantryCombatFoodMultiplier : 1;
|
|
},
|
|
|
|
// The food every land unit will eat today. Unit supply runs after the day's
|
|
// prices are set, so the market needs this forecast to count the army's
|
|
// appetite alongside the civilians' -- otherwise the price only ever sees the
|
|
// civilian half of the demand.
|
|
_militaryFoodNeed() {
|
|
const battle = this._battleUnitIds();
|
|
let total = 0;
|
|
for (const unit of this.units) {
|
|
const proto = this.unitProto(unit);
|
|
if (!proto || !this._isLandUnitProto(proto)) continue;
|
|
total += unitFoodPerDay(proto) * this._unitFoodMultiplier(unit, battle);
|
|
}
|
|
return total;
|
|
},
|
|
|
|
// 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 ration = unitFoodPerDay(proto);
|
|
const multiplier = this._unitFoodMultiplier(unit, battle);
|
|
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 }));
|
|
},
|
|
};
|