Files
Battle-for-Tismo/shared/game_state/economy.js
T
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
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.
2026-09-25 14:58:49 +02:00

1299 lines
52 KiB
JavaScript

// Economy: population and GDP aggregates, budget income and upkeep breakdowns,
// research/culture accrual and the daily economic ticks.
import { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES, RESOURCE_IDS } from "../data/resources.js";
import {
EFFECT_PRODUCTION,
EFFECT_RESEARCH,
EFFECT_CULTURE,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
productionFactors,
productionFromFactors,
governmentEffects,
prerequisitesMet,
technologyEffects,
} from "../rules.js";
import { key, parseKey } from "../hex.js";
import {
TRANSPORT_BY_ID,
tileImprovementById,
isResourceTileBuilding,
} from "../data/improvements.js";
import { tileFoodOutput } from "../resources.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR, DAYS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
// The neutral port/food bonuses for a regionless tile, shared so the common
// inland case never allocates.
const NO_SEA_BONUS = { version: -1, portProduction: 0, foodMultiplier: 1 };
export const economyMethods = {
// ------------------------------------------------------ aggregates --
getPlayerPopulation(civ) {
return Math.round(this._playerAggregates(civ).population);
},
// The live economy: the raw sums over the civ's territory, adjusted by the
// balancing factors map generation assigned so every civilisation starts with
// the same population and production per head. The factors stay applied as
// the raw figures grow, so terrain and war keep shaping the economy without
// ever recreating the arbitrary starting gap.
_playerAggregates(civ) {
const base = this._basePlayerAggregates(civ);
const populationScale = this._populationBalance.get(civ) || 1;
const productionScale = this._productionBalance.get(civ) || 1;
return {
population: base.population * populationScale,
gdp: base.gdp * populationScale * productionScale,
};
},
// The economy exactly as the tiles describe it, before balancing. Every
// tile's per-capita figure already carries its region's and its nation's
// production modifiers, so the sum needs no further adjustment. The walk
// touches every populated tile a nation holds, and the headline figures are
// read all over the snapshot (diplomacy, the nation bar, the budget), so the
// sum is memoised until the population, the territory or any per-tile GDP
// figure actually moves -- not merely because a unit took a step, which is
// what used to drag this walk onto every 10 Hz broadcast.
_basePlayerAggregates(civ) {
const stamp = `${this._gdpEpoch}:${this._populationVersion}:${this._territoryVersion}`;
const cached = this._baseAggregatesCache.get(civ);
if (cached && cached.stamp === stamp) return cached;
let population = 0;
let baseGdp = 0;
const cells = this._territoryByCiv.get(civ) || [];
for (const coords of cells) {
const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0;
// An empty tile contributes nothing and its per-capita figure would be
// multiplied by zero anyway, so the walk skips it entirely.
if (pop <= 0) continue;
population += pop;
baseGdp += pop * this.getTileGdpPerCapita(coords);
}
const result = { stamp, population, gdp: baseGdp };
this._baseAggregatesCache.set(civ, result);
return result;
},
// Drops the whole per-tile GDP memo and stamps a new generation. Every caller
// that used to clear `_gdpPerCapitaCache` goes through here so the per-civ
// aggregate memo below (which sums those figures) can tell the figures moved.
_clearTileGdpCache() {
const fresh = new Map();
this._gdpPerCapitaCache = fresh;
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
},
// Forgets one tile's GDP figure and stamps a new generation, so the aggregate
// memo is not left holding a sum that includes the old value.
_dropTileGdpCache(k) {
const cache = this._gdpPerCapitaCache;
cache.delete(k);
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
},
getTileGdpPerCapita(coords) {
const k = key(coords.x, coords.y);
if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k);
const owner = this.civAt(coords);
if (owner < 0) return 0;
const city = this.cityAt(coords);
const region = city || this.regionCityAt(coords);
const regional = region ? this.getCityBuildingModifiers(region) : null;
const national = this.getNationalModifiers(owner);
const factors = productionFactors(this.tiles[k], {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && this._isNextToCity(coords, owner),
pillagePenalty: this.tileProductionPenalty.get(k) !== undefined
? this.tileProductionPenalty.get(k)
: 1,
// A spy's sabotage zeroes the tile outright for a week.
sabotaged: this.isTileSabotaged(coords),
// Fighting scars the tile too, but that loss recovers over time.
battleDeficit: this.tileBattleProductionDeficit.get(k) || 0,
trench: this.trenches.has(k),
regionalMultiplier: 1 + ((regional && regional[EFFECT_PRODUCTION]) || 0),
regionalLabel: region ? region.name : null,
nationalMultiplier: 1 + (national[EFFECT_PRODUCTION] || 0),
portProduction: this._regionPortProduction(region),
naturalGrowth: this._naturalTileProductionGrowth(k),
});
const perCapita = productionFromFactors(this._regionProductionPerCapita(region), factors);
this._gdpPerCapitaCache.set(k, perCapita);
return perCapita;
},
// GDP does not start from an abstract base figure: the ground a region covers
// produces resources, and their value on the market is what its people make.
// The base the production modifiers scale is that regional production, in
// euro a year, per head. It carries no city, port or terrain bonus of its own
// -- those stay in `productionFactors`, applied on top.
_regionProductionPerCapita(region) {
if (!region) return 0;
const cachedKey = `regionbase:${region.id}`;
if (this._gdpPerCapitaCache.has(cachedKey)) return this._gdpPerCapitaCache.get(cachedKey);
let population = 0;
for (const coords of this.regionTiles(region)) {
population += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
}
const base = population > 0 ? this._regionProductionValue(region) / population : 0;
this._gdpPerCapitaCache.set(cachedKey, base);
return base;
},
// The market value of everything made across a region in a year: the food its
// worked land grows (from the land, not the headcount) plus the output of
// every resource building on it, each at the current world price.
_regionProductionValue(region) {
let daily = 0;
const foodMultiplier = this._regionFoodMultiplier(region);
for (const coords of this.regionTiles(region)) {
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile) continue;
const population = this.tilePopulation.get(k) || 0;
if (population > 0) {
daily += tileFoodOutput(tile) * foodMultiplier * this.getResourcePrice("food");
}
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (isResourceTileBuilding(proto)) {
daily += this.resourceBuildingOutputAt(k, proto) * this.getResourcePrice(proto.resource);
}
}
return daily * DAYS_PER_YEAR;
},
// Snapshots every land tile's opening production, so natural growth has a
// constant to build on. The world and its economy must exist by now.
_initNaturalProductionGrowth() {
this.tileProductionBaseline = new Map();
if (ECONOMY.naturalProductionGrowthPerDay <= 0) return;
for (const k in this.tiles) {
const coords = parseKey(k);
if (!this._isLand(coords)) continue;
const perCapita = this.getTileGdpPerCapita(coords);
if (perCapita > 0) this.tileProductionBaseline.set(k, perCapita);
}
this._clearTileGdpCache();
},
// The flat amount a tile's production per head has gained since January 2000:
// its opening figure times the daily rate, once per day elapsed. A constant,
// so the relative growth rate falls as the tile grows richer.
_naturalTileProductionGrowth(k) {
const baseline = this.tileProductionBaseline ? this.tileProductionBaseline.get(k) : 0;
if (!baseline) return 0;
return baseline * ECONOMY.naturalProductionGrowthPerDay * this._productionGrowthDays();
},
_productionGrowthDays() {
return Math.floor(Math.max(0, this.totalHours) / HOURS_PER_DAY);
},
// The flat production per head a region's ports add to every tile it covers:
// 1000 per level for each sea tile beside the city, summed over its ports.
// Read once per populated tile per day, so the port scan is memoised with the
// food figure below.
_regionPortProduction(region) {
return this._regionSeaBonus(region).portProduction;
},
// The extra food a region's ports bring in, as a multiplier on its tiles'
// output: each port level adds `foodPerSeaTilePerLevel` for every sea tile
// beside the port city. Returns 1 when the region has no port, so the common
// inland case is free.
_regionFoodMultiplier(region) {
return this._regionSeaBonus(region).foodMultiplier;
},
// Both port bonuses at once, memoised per region against the modifier version
// (buildings and ports change only through it). The six neighbouring tiles are
// walked once for the pair instead of once per caller.
_regionSeaBonus(region) {
if (!region) return NO_SEA_BONUS;
const fingerprint = cityBuildingsFingerprint(region.buildings);
const cached = this._regionSeaBonusCache.get(region.id);
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
return cached;
}
let production = 0;
let food = 0;
for (const [index, level] of Object.entries(region.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
production += (proto.productionPerSeaTilePerLevel || 0) * level;
food += (proto.foodPerSeaTilePerLevel || 0) * level;
}
}
let sea = 0;
if (production > 0 || food > 0) {
for (const neighbour of this._neighbours(region.coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") sea += 1;
}
}
const result = {
version: this._modifiersVersion,
fingerprint,
portProduction: production * sea,
foodMultiplier: food > 0 ? 1 + food * sea : 1,
};
this._regionSeaBonusCache.set(region.id, result);
return result;
},
getTileGdp(coords) {
// The population is rounded, as everywhere it is shown, so a tile's GDP is
// exactly its shown people times its shown production per head.
return this.getTilePopulation(coords) * this.getTileGdpPerCapita(coords);
},
getPlayerGdp(civ) {
return this._playerAggregates(civ).gdp;
},
getPlayerUpkeep(civ) {
// Money upkeep is now only propaganda. Everything physical -- buildings,
// units, works, transport -- is maintained in materials and settled by the
// daily resource tick, so it never appears as a direct money bill.
return this.getCampaignUpkeep(civ);
},
// The maintenance materials one nation's buildings wear out in a day, for the
// budget forecast and the resources panel.
getBuildingUpkeep(civ) {
const total = { steel: 0, energy: 0, hightech: 0 };
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const [indexStr, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(indexStr)];
if (!proto) continue;
const costs = this.buildingUpkeepResources(proto, level);
total.steel += costs.steel;
total.energy += costs.energy;
total.hightech += costs.hightech;
}
}
return total;
},
// How many road and railway tiles a nation controls. The count walks the
// whole network and is read for every nation's upkeep both in the budget
// panel and in the snapshot, so it is memoised until a road, railway or
// territory moves. The set sizes are folded in so a direct edit still counts.
_transportTiles(civ) {
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}:${this._territoryVersion}`;
const cached = this._transportCountsCache.get(civ);
if (cached && cached.stamp === stamp) return cached.value;
const counts = { road: 0, railway: 0 };
const sets = [
[this.roads, "road"],
[this.railways, "railway"],
];
for (const [tiles, id] of sets) {
for (const k of tiles) {
if (this.civAt(parseKey(k)) === civ) counts[id] += 1;
}
}
this._transportCountsCache.set(civ, { stamp, value: counts });
return counts;
},
// The daily steel a nation's transport network wears out: every road tile a
// little, every railway tile more.
getTransportUpkeep(civ) {
const counts = this._transportTiles(civ);
return counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
},
// The road and railway lines of the budget breakdown, dropping any empty kind.
_transportUpkeepSources(civ) {
const counts = this._transportTiles(civ);
const sources = [];
if (counts.road > 0) {
const steel = counts.road * RESOURCE_RULES.transportUpkeepSteel.road;
sources.push({
label: TRANSPORT_BY_ID.road.name,
kind: "transport",
count: counts.road,
resources: { steel, energy: 0, hightech: 0 },
amount: this._upkeepCost({ steel }),
});
}
if (counts.railway > 0) {
const steel = counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
sources.push({
label: TRANSPORT_BY_ID.railway.name,
kind: "transport",
count: counts.railway,
resources: { steel, energy: 0, hightech: 0 },
amount: this._upkeepCost({ steel }),
});
}
return sources;
},
// The market value of a day's upkeep materials: what the state would pay to
// buy them, which is what the budget panel forecasts.
_upkeepCost(costs) {
return (costs.steel || 0) * this.getResourcePrice("steel") +
(costs.energy || 0) * this.getResourcePrice("energy") +
(costs.hightech || 0) * this.getResourcePrice("hightech");
},
getCityBuildingLevel(city, protoIndex) {
return city.buildings[protoIndex] || 0;
},
// Population, GDP and the people's approval of one city's region. There is no
// GDP tax any more, so a region's economy carries no income of its own; what
// it contributes to the state is the trade tax it generates, read from the
// tax ledger.
getCityEconomy(city) {
// While a snapshot is being assembled, every caller sees one figure: the
// city stats and the viewer's budget breakdown both need it for the same
// city, and the region walk is the expensive part.
if (this._cityEconomyCacheActive) {
const cached = this._cityEconomyCache.get(city.id);
if (cached) return cached;
}
let population = 0;
let gdp = 0;
for (const coords of this.regionTiles(city)) {
const k = key(coords.x, coords.y);
const pop = this.tilePopulation.get(k) || 0;
// Empty tiles add nothing to the region and need no per-capita figure.
if (pop <= 0) continue;
population += pop;
gdp += pop * this.getTileGdpPerCapita(coords);
}
// The nation-wide balancing factors map generation assigned keep every
// civilisation's start equal; they scale the region figures too, so the
// regions of a nation always add up to its headline population and GDP.
const populationScale = this._populationBalance.get(city.civ) || 1;
const productionScale = this._productionBalance.get(city.civ) || 1;
population *= populationScale;
gdp *= populationScale * productionScale;
const result = {
population,
gdp,
approval: this.taxApprovalMultiplier(city),
};
if (this._cityEconomyCacheActive) this._cityEconomyCache.set(city.id, result);
return result;
},
// The tax the whole nation collects in one hour, the day's take spread over
// its 24 hours.
getCivHourlyIncome(civ) {
return this._taxIncome(this.getTaxTake(civ)) / HOURS_PER_DAY;
},
// The money a nation's tax ledger holds, in whichever shape its model uses:
// the simple economy's single "income" figure, or the hard economy's three
// trade taxes. One helper keeps the two call shapes from drifting.
_taxIncome(take) {
return (take.income || 0) + (take.sales || 0) + (take.export || 0) + (take.import || 0);
},
// The tax sources a nation collected over the last day, as hourly averages,
// in the order the budget panel lists them. The simple economy has one
// ("Tax income"); the hard economy has its three trade taxes.
_taxIncomeSources(civ, cityName = null) {
const take = this.getTaxTake(civ);
const sources = [];
if (take.income) {
sources.push({ label: "Tax income", kind: "tax", amount: take.income / HOURS_PER_DAY });
}
if (take.sales) {
sources.push({ label: "National sales tax", kind: "tax", amount: take.sales / HOURS_PER_DAY });
}
if (take.export) {
sources.push({ label: "Export tariffs", kind: "tax", amount: take.export / HOURS_PER_DAY });
}
if (take.import) {
sources.push({ label: "Import tariffs", kind: "tax", amount: take.import / HOURS_PER_DAY });
}
for (const source of sources) if (cityName) source.cityName = cityName;
return sources;
},
// Income and upkeep with the source behind every figure. The income side is
// the trade taxes the nation collected over the last day, as hourly averages;
// there is no GDP tax. Upkeep is grouped by type with a per-city breakdown the
// budget table can expand.
budgetBreakdown(civ) {
const regions = [];
for (const city of this.cities) {
if (city.civ !== civ) continue;
const economy = this.getCityEconomy(city);
const tax = this.getCityTaxTake(city);
regions.push({
cityId: city.id,
cityName: city.name,
isCapital: city.isCapital,
population: economy.population,
gdp: economy.gdp,
approval: economy.approval,
taxes: tax,
income: this._taxIncome(tax) / HOURS_PER_DAY,
});
}
const income = regions.reduce((sum, region) => sum + region.income, 0);
const incomeSources = this._sortSources(this._taxIncomeSources(civ));
const groups = this._buildingUpkeepGroups(civ);
const buildingSources = this._sortSources(
groups.map((group) => ({ label: group.label, kind: "building", amount: group.amount }))
);
const buildingUpkeep = groups.reduce((sum, group) => sum + group.amount, 0);
const unitSources = this._unitUpkeepSources(civ);
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
const campaignSources = this._campaignUpkeepSources(civ);
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
const transportSources = this._transportUpkeepSources(civ);
const transportUpkeep = transportSources.reduce((sum, source) => sum + source.amount, 0);
const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
const tileImprovementUpkeep =
tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
// What the nation spent buying and earned selling each resource over the
// last day, for the budget panel's trade section.
const record = this.resourceSpend.get(civ) || {};
const buy = record.buy || {};
const sell = record.sell || {};
const resourceMoney = RESOURCE_IDS
.map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
.filter((entry) => entry.bought > 0 || entry.sold > 0);
// What the nation actually paid for materials, power and freight over the
// last day, as hourly averages. The upkeep rows above are a forecast at
// market prices; these are the real bills the stores and the market settled,
// shown as their own line so the two are never confused.
const resourceCosts = [
{ id: "energy", label: "Electric imports", amount: (record.energy || 0) / HOURS_PER_DAY },
{ id: "goods", label: "Goods", amount: (record.goods || 0) / HOURS_PER_DAY },
{ id: "transport", label: "Freight", amount: (record.transport || 0) / HOURS_PER_DAY },
{ id: "upkeep", label: "Material upkeep", amount: (record.upkeep || 0) / HOURS_PER_DAY },
{ id: "construction", label: "Construction", amount: (record.construction || 0) / HOURS_PER_DAY },
{ id: "combat", label: "Munitions", amount: (record.combat || 0) / HOURS_PER_DAY },
].filter((entry) => entry.amount > 0);
return {
income: { rate: 0, base: 0, modifier: 0, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
transportUpkeep: { total: transportUpkeep, sources: transportSources, groups: transportSources },
tileUpkeep: {
total: tileImprovementUpkeep,
sources: tileImprovementSources,
groups: tileImprovementSources,
},
resourceMoney,
resourceCosts,
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
// The economy of one city's region, shaped like `budgetBreakdown` so the UI
// renders both with one table: the trade taxes the region generated, and the
// upkeep of the city's own buildings. National unit upkeep is not attributed
// to a region, so it is left out and the net is what the region contributes to
// the state minus what its buildings cost it.
// `economy`, when supplied, is the already-computed `getCityEconomy` result,
// so a caller that needs both does not derive the region twice.
cityBudgetBreakdown(city, economy = null) {
if (!economy) economy = this.getCityEconomy(city);
const tax = this.getCityTaxTake(city);
const income = this._taxIncome(tax) / HOURS_PER_DAY;
const region = {
cityId: city.id,
cityName: city.name,
isCapital: city.isCapital,
population: economy.population,
gdp: economy.gdp,
approval: economy.approval,
taxes: tax,
income,
};
const incomeSources = this._sortSources(this._taxIncomeSources(city.civ, city.name));
const groups = this._cityBuildingUpkeepGroups(city);
const buildingSources = this._sortSources(
groups.map((group) => ({ label: group.label, kind: "building", amount: group.amount }))
);
const buildingUpkeep = groups.reduce((sum, group) => sum + group.amount, 0);
return {
income: {
rate: 0,
base: 0,
modifier: 0,
total: income,
sources: incomeSources,
regions: [region],
},
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: 0, sources: [], groups: [] },
net: income - buildingUpkeep,
};
},
// Upkeep of one city's buildings, grouped by type. Like the national groups
// but carrying only the hosting city, so the region table can expand it.
_cityBuildingUpkeepGroups(city) {
const groups = [];
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const level = city.buildings[indexStr];
const proto = this.protoBuildings[index];
if (!proto || level <= 0) continue;
const resources = this.buildingUpkeepResources(proto, level);
const amount = this._upkeepCost(resources);
if (amount === 0) continue;
groups.push({
index,
label: proto.name,
amount,
resources,
cities: [{ cityId: city.id, cityName: city.name, level, amount, resources }],
});
}
return groups.sort((a, b) => b.amount - a.amount);
},
// Upkeep of every building the nation keeps, grouped by building type and
// carrying the per-city breakdown the budget table expands.
_buildingUpkeepGroups(civ) {
const byType = new Map();
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const level = city.buildings[indexStr];
const proto = this.protoBuildings[index];
if (!proto || level <= 0) continue;
const resources = this.buildingUpkeepResources(proto, level);
const amount = this._upkeepCost(resources);
if (amount === 0) continue;
const group = byType.get(index) || {
index,
label: proto.name,
amount: 0,
resources: { steel: 0, energy: 0, hightech: 0 },
cities: [],
};
group.amount += amount;
group.resources.steel += resources.steel;
group.resources.energy += resources.energy;
group.resources.hightech += resources.hightech;
group.cities.push({ cityId: city.id, cityName: city.name, level, amount, resources });
byType.set(index, group);
}
}
const groups = Array.from(byType.values());
for (const group of groups) group.cities.sort((a, b) => b.amount - a.amount);
return groups.sort((a, b) => b.amount - a.amount);
},
_unitUpkeepSources(civ) {
const sources = new Map();
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);
const resources = {
steel: costs.steel * multiplier,
energy: 0,
hightech: costs.hightech * multiplier,
};
const amount = this._upkeepCost(resources);
if (amount === 0) continue;
const entry = sources.get(unit.proto) || {
label: proto.name,
kind: "unit",
amount: 0,
resources: { steel: 0, energy: 0, hightech: 0 },
count: 0,
};
entry.amount += amount;
entry.resources.steel += resources.steel;
entry.resources.hightech += resources.hightech;
entry.count += 1;
sources.set(unit.proto, entry);
}
return this._sortSources(Array.from(sources.values()));
},
_sortSources(sources) {
const weight = (source) => (source.amount !== undefined ? source.amount : source.effect);
return sources.sort((a, b) => weight(b) - weight(a));
},
// ------------------------------------------------------- modifiers --
getCivBuildingModifiers(civ) {
const totals = {};
for (const city of this.cities) {
if (city.civ !== civ) continue;
mergeModifiers(totals, this.getCityBuildingModifiers(city));
}
return totals;
},
// The modifiers one city's own buildings give. Improvements only shape the
// region their city controls, so a tile's per-capita draws on this. A
// per-capita figure is derived for every populated tile each day, so the same
// handful of cities would otherwise have their buildings re-summed thousands
// of times; the totals are memoised against the modifier version, which every
// building change bumps.
getCityBuildingModifiers(city) {
const fingerprint = cityBuildingsFingerprint(city.buildings);
const cached = this._cityBuildingModifierCache.get(city.id);
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
return cached.value;
}
const totals = {};
for (const indexStr of Object.keys(city.buildings)) {
const proto = this.protoBuildings[Number(indexStr)];
const level = city.buildings[indexStr];
for (const e of proto.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level);
}
}
this._cityBuildingModifierCache.set(city.id, {
version: this._modifiersVersion,
fingerprint,
value: totals,
});
return totals;
},
getCivGovernmentModifiers(civ) {
const resource = this.governments[this.getGovernment(civ)];
return resource ? governmentEffects(resource) : {};
},
// Government and research reach every region of the nation; only city
// buildings are regional. Like the per-city totals, this is read once per
// populated tile per day, so it is memoised against the modifier version.
getNationalModifiers(civ) {
const cached = this._nationalModifierCache.get(civ);
if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = {};
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
this._nationalModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
// Nation-wide modifier totals, memoised because pathfinding reads the rail
// speed for every tile it expands and rebuilding the totals there is costly.
getCivModifiers(civ) {
const cached = this._civModifierCache.get(civ);
if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = this.getCivBuildingModifiers(civ);
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
this._civModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
// Invalidates the memoised modifier totals after anything that shapes them
// changes: research, buildings or government.
_touchModifiers() {
this._modifiersVersion += 1;
},
// Construction and training take this much longer than the base time; a
// construction technology level of 1% shortens it by 1%.
constructionSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_CONSTRUCTION_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// The time multiplier for travelling a railway: a trains technology level of
// 1% moves 1% faster along rail.
railSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_RAIL_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// ------------------------------------------- research and culture --
getCulture(civ) {
return this.culture.get(civ) || 0;
},
getGovernment(civ) {
return this.government.get(civ) || 0;
},
getGovernmentResource(civ) {
const index = this.getGovernment(civ);
if (index < 0 || index >= this.governments.length) return null;
return this.governments[index];
},
isResearched(civ, index) {
const set = this.researched.get(civ);
return set ? set.has(index) : false;
},
// Whether a nation knows a technology, by its catalogue id. Used to gate the
// things only late research unlocks (the fusion plant).
hasTechnology(civ, id) {
const index = this.technologies.findIndex((tech) => tech.id === id);
return index >= 0 && this.isResearched(civ, index);
},
getResearchedIndices(civ) {
const set = this.researched.get(civ) || new Set();
return Array.from(set);
},
canResearchTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.technologies.length) return false;
const tech = this.technologies[index];
// A repeatable technology may be researched again; any other is done once.
if (!tech.repeatable && this.isResearched(civ, index)) return false;
return prerequisitesMet(
tech,
this.researched.get(civ) || new Set(),
this.technologies,
(id) => this._civHasBuilding(civ, id)
);
},
// Whether any of a nation's cities has at least one level of the named
// building. Gates a technology that a building unlocks, such as the uranium
// enrichment centre opening nuclear weapons.
_civHasBuilding(civ, id) {
for (const city of this.cities) {
if (city.civ === civ && this.hasCityBuilding(city, id)) return true;
}
return false;
},
// The technology a nation is currently focusing its research on, or -1.
getFocusedTechnology(civ) {
const index = this.focusTechnology.get(civ);
return index === undefined ? -1 : index;
},
// The research points already committed to a technology. With no index the
// current focus's progress is returned, which is what the panel usually wants.
getTechnologyProgress(civ, index = this.getFocusedTechnology(civ)) {
if (index < 0) return 0;
const map = this.techProgress.get(civ);
return map ? map.get(index) || 0 : 0;
},
// How many times a repeatable technology has been researched.
getTechnologyRepeatCount(civ, index) {
const tech = this.technologies[index];
if (!tech) return 0;
const counts = this.repeatCounts.get(civ);
return counts ? counts.get(tech.id) || 0 : 0;
},
// The cost of the next level of a technology, growing 2% each repeat.
getTechnologyCost(civ, index) {
return technologyCost(this.technologies[index], this.getTechnologyRepeatCount(civ, index));
},
getCivTechnologyModifiers(civ) {
const totals = {};
const civResearch = this.researched.get(civ) || new Set();
for (const index of civResearch) {
const tech = this.technologies[index];
if (!tech || tech.repeatable) continue;
mergeModifiers(totals, technologyEffects(tech));
}
// Repeatable technologies stack their effect once per researched level.
const counts = this.repeatCounts.get(civ);
if (counts) {
for (const [id, count] of counts) {
const tech = technologyById(id);
if (!tech || count <= 0) continue;
for (const e of tech.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, count);
}
}
}
return totals;
},
// -------------------------------------------------------- research --
// Points a nation creates flow straight into its focused technology; there is
// no bank, so time spent with nothing focused is simply time not advancing.
// A joint-research treaty shares the points with the partners' own focused
// technology, except when the technology being researched is military.
_addResearch(civ, amount) {
if (!(amount > 0)) return;
const index = this.getFocusedTechnology(civ);
if (index < 0) return;
this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
const tech = this.technologies[index];
if (!tech || tech.theme === "military") return;
// The partners' copies must not echo back to the origin, so one sharing
// pass is guarded from re-entering.
if (this._sharingResearch) return;
const partners = this.jointResearchPartners(civ);
if (partners.length === 0) return;
this._sharingResearch = true;
try {
for (const partner of partners) {
const partnerIndex = this.getFocusedTechnology(partner);
if (partnerIndex < 0) continue;
const partnerTech = this.technologies[partnerIndex];
if (partnerTech && partnerTech.theme === "military") continue;
this._applyTechnologyProgress(
partner,
partnerIndex,
this.getTechnologyProgress(partner, partnerIndex) + amount
);
}
} finally {
this._sharingResearch = false;
}
},
// Records the points committed to one technology.
_setTechnologyProgress(civ, index, progress) {
let map = this.techProgress.get(civ);
if (!map) {
map = new Map();
this.techProgress.set(civ, map);
}
if (progress > 0) map.set(index, progress);
else map.delete(index);
},
// Commits `progress` points to a technology. Any overflow past its cost is
// discarded: nothing is carried over to whatever is focused next.
_applyTechnologyProgress(civ, index, progress) {
const cost = this.getTechnologyCost(civ, index);
if (progress < cost) {
this._setTechnologyProgress(civ, index, progress);
return;
}
this._discoverTechnology(civ, index);
// The completed level spends its points; a repeatable starts the next level
// from zero. Other technologies keep whatever they had banked.
this._setTechnologyProgress(civ, index, 0);
// A repeatable technology that was just completed comes straight back into
// focus, so its next level keeps accruing without the player having to ask
// again. Anything else clears the focus as before.
if (this.technologies[index] && this.technologies[index].repeatable) {
this.focusTechnology.set(civ, index);
} else {
this._clearFocus(civ);
}
this._clearTileGdpCache();
this._emitChanged();
},
// Clears the focus only: progress already committed to any technology stays
// put, so switching focus and coming back resumes where the nation left off.
_clearFocus(civ) {
this.focusTechnology.set(civ, -1);
},
// Marks a technology known. A repeatable one instead counts one more level;
// its notice is flagged so the news list can stay quiet while the feed still
// tells the player. Either way it joins the researched set, so a repeatable
// can still satisfy another technology's prerequisites.
_discoverTechnology(civ, index) {
const tech = this.technologies[index];
if (!tech) return;
this.researched.get(civ).add(index);
if (tech.repeatable) {
const counts = this.repeatCounts.get(civ);
if (counts) counts.set(tech.id, (counts.get(tech.id) || 0) + 1);
}
this._touchModifiers();
this._addNews({
type: NEWS_TECHNOLOGY,
civ,
technology: tech.id,
name: tech.name,
repeatable: !!tech.repeatable,
});
},
getBudget(civ) {
return this.budgets.has(civ) ? this.budgets.get(civ) : 0;
},
// Deducts `amount` from a nation's budget, returning false (and charging
// nothing) when it cannot afford it. A non-positive amount is always free.
// When a `category` is given the spend is filed into the month's budget
// ledger, so the panel can report what each kind of outlay cost.
// The government can always pay: a spend is never refused for want of money.
// A treasury driven below zero is an overdraft, a debt owed to the central
// bank, and carries interest until the budget recovers.
_spendBudget(civ, amount, category = null) {
if (amount <= 0) return true;
this.budgets.set(civ, this.getBudget(civ) - amount);
// The money does not vanish: it is paid into the nation's own regions, the
// private sector that supplied the labour or service.
this._creditRegions(civ, amount);
if (category) this._recordBudgetCash(civ, category, -amount);
return true;
},
// How far a nation's treasury has been driven into the red, owed to its
// central bank.
getGovernmentDebt(civ) {
return Math.max(0, -this.getBudget(civ));
},
// Puts money back into the treasury, reversing a spend. With a category it
// cancels part of that category's month total, so a refunded order does not
// linger as an expense.
_refundBudget(civ, amount, category = null) {
if (!(amount > 0)) return;
this.budgets.set(civ, this.getBudget(civ) + amount);
// A refund claws the money back from the regions it was paid to.
this._chargeRegions(civ, amount);
if (category) this._recordBudgetCash(civ, category, amount);
},
getTraining(cityId) {
return this.training.get(cityId) || null;
},
// ---------------------------------------------------- daily ticks --
_tickPopulation() {
const factor = ECONOMY.populationGrowthFactor(HOURS_PER_YEAR, HOURS_PER_DAY);
if (factor === 0) return;
for (const k of Array.from(this.tilePopulation.keys())) {
this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor));
}
this._populationVersion += 1;
},
// The people living in one city's region, summed over its tiles.
regionPopulation(city) {
let total = 0;
for (const coords of this.regionTiles(city)) {
total += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
}
return total;
},
// A region's population with the same balancing factor `getCityEconomy` uses,
// but without walking every tile's GDP. Callers that only need the headcount
// (food needs, supplier reserves) should use this rather than pay for the
// production figures.
getCityPopulation(city) {
return this.regionPopulation(city) * (this._populationBalance.get(city.civ) || 1);
},
// Pulls `amount` people out of a city's region into a unit, choosing the
// tiles at random in proportion to how many people each holds. Returns how
// many were actually drawn (never more than the region had).
drawRegionPopulation(city, amount) {
if (!(amount > 0)) return 0;
const tiles = [];
let total = 0;
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 0;
const wanted = Math.min(amount, total);
let remaining = wanted;
while (remaining > 0 && tiles.length > 0) {
let pick = this._random() * total;
let index = 0;
for (; index < tiles.length - 1; index++) {
pick -= tiles[index].population;
if (pick <= 0) break;
}
const tile = tiles[index];
const take = Math.min(tile.population, remaining);
tile.population -= take;
total -= take;
remaining -= take;
this.tilePopulation.set(tile.k, tile.population);
if (tile.population <= 0) tiles.splice(index, 1);
}
this._populationVersion += 1;
return wanted;
},
_tickEconomy() {
const hours = HOURS_PER_DAY;
// There is no GDP tax to collect: the government's trade taxes are booked as
// the trades happen. All this tick still does is pay the day's propaganda
// spending back out to the private sector.
for (const civ of Array.from(this.budgets.keys())) {
const upkeep = this.getPlayerUpkeep(civ) * hours;
if (upkeep > 0) this._spendBudget(civ, upkeep, "campaign");
}
},
_tickBuildings() {
for (let civ = 0; civ < this.civilisations.length; civ++) {
const modifiers = this.getCivModifiers(civ);
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
// Science buildings spend a unit of high-tech for each research point
// they make, so a region short of chips researches only as far as its
// store reaches, and reports the shortfall. Government research needs no
// high-tech.
const buildingRate = this.getCivBuildingModifiers(civ)[EFFECT_RESEARCH] || 0;
let effectiveBuilding = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
const cityRate = this.getCityBuildingModifiers(city)[EFFECT_RESEARCH] || 0;
if (cityRate <= 0) continue;
effectiveBuilding += this._spendCityHighTechForResearch(civ, city, cityRate);
}
this._addResearch(civ, scienceRate - buildingRate + effectiveBuilding);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
_tickTraining() {
const completed = [];
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
for (const [cityId, queue] of this.training) {
const city = this.findCity(cityId);
if (!city) {
completed.push(cityId);
continue;
}
// A queued order -- building or unit -- first gathers its materials, once
// a day, exactly as a tile improvement's construction site does; only then
// does its construction or training time begin.
for (const entry of queue) {
if (entry.phase !== "materials") continue;
if (entry.lastGatherDay === day) continue;
entry.lastGatherDay = day;
this._gatherBuildEntry(city, entry);
}
// A city runs one production line per kind plus whatever its Barracks
// (units) and Shipyards (buildings) add. Each line works one entry at full
// capacity, so a higher-level city produces several things at once; the
// overflow of a finished entry carries into the next waiting one.
const unitLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
this._advanceProduction(queue, city, "unit", unitLines);
this._advanceProduction(queue, city, "building", buildingLines);
if (queue.length === 0) completed.push(cityId);
}
for (const cityId of completed) this.training.delete(cityId);
},
// Total levels of the buildings carrying `mechanic`, so a city with two
// barracks levels gets two extra production lines.
_cityMechanicLevel(city, mechanic) {
let total = 0;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === mechanic) total += level;
}
return total;
},
// Spends one hour of production on up to `lines` waiting entries of `kind`,
// one line each. Entries of the other kind are left to their own lines, so a
// queued building never blocks a unit and vice versa. A line sticks with its
// entry: when one finishes before its hour is up the leftover flows into the
// next waiting entry of the same kind, while the other lines keep working
// theirs, so a cheap unit queued behind a costly one still lands first.
_advanceProduction(queue, city, kind, lines) {
if (lines <= 0) return;
let freeLines = lines;
let overflow = 0;
let i = 0;
while (i < queue.length && (freeLines > 0 || overflow > 0)) {
const entry = queue[i];
if (entry.kind !== kind) {
i += 1;
continue;
}
// A building still gathering its materials is not on a construction line
// yet, so it neither works nor blocks the ones that are.
if (entry.phase === "materials") {
i += 1;
continue;
}
const proto = this._queueEntryProto(entry);
if (!proto) {
queue.splice(i, 1);
continue;
}
let budget = overflow;
overflow = 0;
if (freeLines > 0) {
budget += 1;
freeLines -= 1;
}
const remaining = Math.max(entry.totalHours - entry.elapsedHours, 0);
if (budget >= remaining) {
overflow = budget - remaining;
if (kind === "building") this._completeBuilding(city, entry);
else {
// Hand back whatever material budget the unit did not spend, then
// place it.
this._releaseEntryBudget(city.civ, entry);
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
}
queue.splice(i, 1);
} else {
entry.elapsedHours += budget;
i += 1;
}
}
},
// The catalogue entry a queue entry produces, unit or building.
_queueEntryProto(entry) {
const index = entry.protoIndex;
if (entry.kind === "building") {
return index >= 0 && index < this.protoBuildings.length ? this.protoBuildings[index] : null;
}
return index >= 0 && index < this.protoUnits.length ? this.protoUnits[index] : null;
},
// Raises one level of a completed building. The money cost was paid when the
// order was placed and the materials gathered as it ran, so this only returns
// any unspent material budget, changes the city and invalidates the GDP cache.
_completeBuilding(city, entry) {
this._releaseEntryBudget(city.civ, entry);
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._clearTileGdpCache();
this._touchModifiers();
// A nation's first uranium enrichment centre may be noticed abroad.
const proto = this.protoBuildings[entry.protoIndex];
if (
level === 0 &&
proto &&
proto.mechanic === BUILDING_MECHANIC.NUCLEAR_ENRICHMENT
) {
this._warnOfEnrichment(city.civ);
}
},
// Units resting inside one of their own cities recover a little health each
// in-game hour, at a materials cost -- steel for a ground or naval unit, plus
// high-tech for an aircraft or radar -- and by drawing replacement soldiers
// out of the region's civilians. A nation that can find neither does not heal.
_tickGarrisonHealing() {
for (const unit of this.units) {
if (unit.hp >= unit.maxHp) continue;
const city = this.cityAt(unit.coords);
if (!city || city.civ !== unit.civ) continue;
const proto = this.unitProto(unit);
if (!proto) continue;
const heal = Math.min(unit.maxHp - unit.hp, CITY_GARRISON_HEAL_PER_HOUR);
if (heal <= 0) continue;
// Each hit point restored is a share of the unit's soldiers made good from
// the city's people; without enough people, only part of the heal happens.
const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
const soldierNeed = soldiers > 0 ? (heal / unit.maxHp) * soldiers : 0;
const available = this.regionPopulation(city);
const popFraction = soldierNeed > 0 ? Math.min(1, available / soldierNeed) : 1;
const targetHeal = heal * popFraction;
if (targetHeal <= 0) continue;
const repair = RESOURCE_RULES.repair;
const wanted = {
steel: targetHeal * repair.unitSteelPerHp,
hightech: proto.advanced ? targetHeal * repair.unitHighTechPerHp : 0,
};
const payer = this._treasuryPayer(city.civ);
const fromStock = this._drawFromNearest(city.civ, city.coords, wanted, payer);
const shortfall = {
steel: wanted.steel - fromStock.steel,
hightech: wanted.hightech - fromStock.hightech,
};
const v = this.currencyValue(city.civ);
let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v;
let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v;
let paid = 1;
let gotSteel = 0;
let gotTech = 0;
if (shortfall.steel > 0 || shortfall.hightech > 0) {
// The replacement steel and high-tech come from reachable regions,
// bought by the treasury. Whatever is not available is not healed.
const nodes = this._resourceNodes();
const haul = { steel: 0, hightech: 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;
}
if (gotSteel + 1e-6 < shortfall.steel || gotTech + 1e-6 < shortfall.hightech) {
paid = 0;
}
}
if (spentSteel + spentTech > 0) {
this._recordBudgetCash(city.civ, "units", -(spentSteel + spentTech));
this._recordBudgetCategoryResource(
city.civ, "units", "steel", spentSteel, proto.name, fromStock.steel + gotSteel
);
this._recordBudgetCategoryResource(
city.civ, "units", "hightech", spentTech, proto.name, fromStock.hightech + gotTech
);
this._recordResourceSpend(city.civ, "upkeep", spentSteel + spentTech, {
steel: spentSteel,
hightech: spentTech,
});
}
const gained = targetHeal * paid;
if (gained > 0) {
unit.hp = Math.min(unit.maxHp, unit.hp + gained);
const drawn = soldiers > 0 ? (gained / unit.maxHp) * soldiers : 0;
if (drawn > 0) this.drawRegionPopulation(city, drawn);
}
}
},
};
// A compact fingerprint of a city's building levels. The modifier and port
// memos key on it so a direct edit -- a test raising a museum, a save being
// loaded -- is noticed even when the modifier-version counter was not bumped.
// Only the handful of actually-set buildings are walked, so it stays cheap.
function cityBuildingsFingerprint(buildings) {
let fingerprint = "";
for (const index in buildings) {
fingerprint += `${index}=${buildings[index]};`;
}
return fingerprint;
}