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.
1299 lines
52 KiB
JavaScript
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;
|
|
}
|