The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
1210 lines
48 KiB
JavaScript
1210 lines
48 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,
|
|
// 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) {
|
|
const take = this.getTaxTake(civ);
|
|
return (take.sales + take.export + take.import) / HOURS_PER_DAY;
|
|
},
|
|
|
|
// The three trade taxes a nation collected over the last day, as hourly
|
|
// averages, in the order the budget panel lists them.
|
|
_taxIncomeSources(civ, cityName = null) {
|
|
const take = this.getTaxTake(civ);
|
|
const sources = [
|
|
{ label: "National sales tax", kind: "tax", amount: take.sales / HOURS_PER_DAY },
|
|
{ label: "Export tariffs", kind: "tax", amount: take.export / HOURS_PER_DAY },
|
|
{ label: "Import tariffs", kind: "tax", amount: take.import / HOURS_PER_DAY },
|
|
];
|
|
for (const source of sources) if (cityName) source.cityName = cityName;
|
|
return sources.filter((source) => source.amount > 0);
|
|
},
|
|
|
|
// 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: (tax.sales + tax.export + tax.import) / 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);
|
|
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,
|
|
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 = (tax.sales + tax.export + tax.import) / 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);
|
|
},
|
|
|
|
// 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.
|
|
_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);
|
|
},
|
|
|
|
// 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);
|
|
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 building first gathers its materials, once a day, exactly as a
|
|
// tile improvement's construction site does; only then does its
|
|
// construction time begin.
|
|
for (const entry of queue) {
|
|
if (entry.kind !== "building" || 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 {
|
|
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();
|
|
},
|
|
|
|
// 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;
|
|
}
|