Files
Battle-for-Tismo/shared/game_state/economy.js
T
adrien b2ab4d76eb Sped up large battles and trimmed snapshot traffic
- The bombardment overlay was rebuilt every animation frame, and each barrage
  scanned the whole army to find its target (O(strikes x units)). It now
  redraws at most at the renderer's 24 fps, reuses its SVG/DOM nodes, and
  resolves garrisons from one per-snapshot tile index, so a high-refresh
  display no longer makes a big battle worse.
- Route truncation had the same shape: every step of every route rescanned
  every unit. The snapshot now buckets the army by tile once.
- Idle units no longer have identical left/top rewritten every frame, and the
  whole-map fog buffer is rebuilt less often.
- Snapshot traffic: cityStats and visible are delta-encoded like territory and
  population, city stats carry a content-hash version, tile ethnicity is
  memoised, and a city's economy is derived once per broadcast instead of
  twice. A per-peer civ change now forces a full snapshot, since the visible
  set is per-viewer.
2026-09-21 02:23:04 +02:00

804 lines
30 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 {
EFFECT_GDP,
EFFECT_BUDGET_INCOME,
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 { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
gdpPerCapitaFactors,
gdpPerCapitaFromFactors,
governmentEffects,
prerequisitesMet,
technologyEffects,
} from "../rules.js";
import { key, parseKey } from "../hex.js";
import { TRANSPORT_BY_ID } from "../data/improvements.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
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 GDP per capita. 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 perCapitaScale = this._perCapitaBalance.get(civ) || 1;
return {
population: base.population * populationScale,
gdp: base.gdp * populationScale * perCapitaScale,
};
},
// 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
// modifiers, so the sum needs no further adjustment.
_basePlayerAggregates(civ) {
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;
population += pop;
baseGdp += pop * this.getTileGdpPerCapita(coords);
}
return { population, gdp: baseGdp };
},
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 = gdpPerCapitaFactors(this.tiles[k], {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && this._isNextToCity(coords, owner),
pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
// Fighting scars the tile too, but that loss recovers over time.
battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
trench: this.trenches.has(k),
regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
regionalLabel: region ? region.name : null,
nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
});
const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors);
this._gdpPerCapitaCache.set(k, perCapita);
return perCapita;
},
getTileGdp(coords) {
return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords);
},
getPlayerGdp(civ) {
return this._playerAggregates(civ).gdp;
},
getPlayerUpkeep(civ) {
let total = this.getBuildingUpkeep(civ);
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));
total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
}
// Running propaganda campaigns are upkeep too, paid hour by hour.
total += this.getCampaignUpkeep(civ);
// The transport network and military tile improvements cost an hourly
// maintenance per tile and structure held.
total += this.getTransportUpkeep(civ);
total += this.getTileImprovementUpkeep(civ);
return total;
},
// How many road and railway tiles a nation controls.
_transportTiles(civ) {
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;
}
}
return counts;
},
// The hourly maintenance of a nation's transport network: every road tile it
// holds costs a flat fee, every railway tile three times as much.
getTransportUpkeep(civ) {
const counts = this._transportTiles(civ);
return counts.road * TRANSPORT_BY_ID.road.maintenance +
counts.railway * TRANSPORT_BY_ID.railway.maintenance;
},
// 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) {
sources.push({
label: TRANSPORT_BY_ID.road.name,
kind: "transport",
count: counts.road,
amount: counts.road * TRANSPORT_BY_ID.road.maintenance,
});
}
if (counts.railway > 0) {
sources.push({
label: TRANSPORT_BY_ID.railway.name,
kind: "transport",
count: counts.railway,
amount: counts.railway * TRANSPORT_BY_ID.railway.maintenance,
});
}
return sources;
},
getBuildingUpkeep(civ) {
let total = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
total += buildingUpkeep(this.protoBuildings[index], city.buildings[indexStr]);
}
}
return total;
},
getCityBuildingLevel(city, protoIndex) {
return city.buildings[protoIndex] || 0;
},
// Population, GDP and the tax one city's region yields per hour. The tax is
// collected by the region alone: a city's income modifiers never apply to
// another city's land, only government and research reach every region.
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;
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 perCapitaScale = this._perCapitaBalance.get(city.civ) || 1;
population *= populationScale;
gdp *= populationScale * perCapitaScale;
const rate = ECONOMY.budgetGdpRate;
const base = gdp * rate;
const cityMods = this.getCityBuildingModifiers(city);
const national = this.getNationalModifiers(city.civ);
const modifier =
(cityMods[EFFECT_BUDGET_INCOME] || 0) + (national[EFFECT_BUDGET_INCOME] || 0);
// A region whose people have turned against the government barely pays its
// taxes, so approval scales the collected income after every other bonus.
const approval = this.taxApprovalMultiplier(city);
const result = {
population,
gdp,
base,
modifier,
approval,
income: base * (1 + modifier) * approval,
};
if (this._cityEconomyCacheActive) this._cityEconomyCache.set(city.id, result);
return result;
},
// The tax the whole nation collects in one hour, summed over its regions.
getCivHourlyIncome(civ) {
let total = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
total += this.getCityEconomy(city).income;
}
return total;
},
// Income and upkeep with the source behind every figure, so the UI can show
// which buildings, government or technologies shape the budget. Amounts are
// per in-game hour, matching the rest of the economy figures. Income is
// aggregated from the regions; buildings are grouped by type with a per-city
// breakdown the budget table can expand.
budgetBreakdown(civ) {
const rate = ECONOMY.budgetGdpRate;
const regions = [];
for (const city of this.cities) {
if (city.civ !== civ) continue;
const economy = this.getCityEconomy(city);
const buildingModifier = this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0;
regions.push({
cityId: city.id,
cityName: city.name,
isCapital: city.isCapital,
population: economy.population,
gdp: economy.gdp,
base: economy.base,
buildingModifier,
modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0,
approval: economy.approval,
income: economy.income,
});
}
const base = regions.reduce((sum, region) => sum + region.base, 0);
const income = regions.reduce((sum, region) => sum + region.income, 0);
const modifier = base > 0 ? income / base - 1 : 0;
const incomeSources = this._incomeSources(civ, regions, base);
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);
return {
income: { rate, base, modifier, 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,
},
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 tax the region collects with its building,
// government and technology sources, 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 the region's own income minus its buildings.
// `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 rate = ECONOMY.budgetGdpRate;
const region = {
cityId: city.id,
cityName: city.name,
isCapital: city.isCapital,
population: economy.population,
gdp: economy.gdp,
base: economy.base,
buildingModifier: this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0,
modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0,
approval: economy.approval,
income: economy.income,
};
const incomeSources = this._incomeSources(city.civ, [region], region.base);
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,
base: region.base,
modifier: region.modifier,
total: region.income,
sources: incomeSources,
regions: [region],
},
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: 0, sources: [], groups: [] },
net: region.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 amount = buildingUpkeep(this.protoBuildings[index], level);
if (amount === 0) continue;
groups.push({
index,
label: this.protoBuildings[index].name,
amount,
cities: [{ cityId: city.id, cityName: city.name, level, amount }],
});
}
return groups.sort((a, b) => b.amount - a.amount);
},
// Every building, government and researched technology that shifts budget
// income. A building's bonus is measured against the region that hosts it,
// while government and research are measured against the whole nation.
_incomeSources(civ, regions, base) {
const sources = new Map();
const add = (key, label, kind, effect, amount, region) => {
if (effect === 0) return;
const entry = sources.get(key) || {
label,
kind,
effect: 0,
amount: 0,
cityId: region ? region.cityId : null,
cityName: region ? region.cityName : null,
};
entry.effect += effect;
entry.amount += amount;
sources.set(key, entry);
};
for (const region of regions) {
const city = this._cityById.get(region.cityId);
if (!city) continue;
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) {
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
const effect = effectAt(e, level);
add(`building:${proto.name}:${city.id}`, proto.name, "building", effect, region.base * effect, region);
}
}
}
const government = this.governments[this.getGovernment(civ)];
if (government) {
for (const e of government.effects) {
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
const effect = effectAt(e, 1);
add(`government:${government.name}`, government.name, "government", effect, base * effect);
}
}
const researched = this.researched.get(civ) || new Set();
for (const index of researched) {
const tech = this.technologies[index];
for (const e of tech.effects) {
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
const effect = effectAt(e, 1);
add(`technology:${tech.name}`, tech.name, "technology", effect, base * effect);
}
}
return this._sortSources(Array.from(sources.values()));
},
// 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 amount = buildingUpkeep(this.protoBuildings[index], level);
if (amount === 0) continue;
const group = byType.get(index) || {
index,
label: this.protoBuildings[index].name,
amount: 0,
cities: [],
};
group.amount += amount;
group.cities.push({ cityId: city.id, cityName: city.name, level, amount });
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);
},
_buildingUpkeepSources(civ) {
return this._buildingUpkeepGroups(civ).map((group) => ({
label: group.label,
kind: "building",
amount: group.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 || !proto.upkeep) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
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.
getCityBuildingModifiers(city) {
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);
}
}
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.
getNationalModifiers(civ) {
const totals = {};
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
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;
},
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._gdpPerCapitaCache.clear();
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.
_discoverTechnology(civ, index) {
const tech = this.technologies[index];
if (!tech) return;
if (tech.repeatable) {
const counts = this.repeatCounts.get(civ);
if (counts) counts.set(tech.id, (counts.get(tech.id) || 0) + 1);
} else {
this.researched.get(civ).add(index);
}
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.
_spendBudget(civ, amount) {
if (amount <= 0) return true;
if (this.getBudget(civ) < amount) return false;
this.budgets.set(civ, this.getBudget(civ) - amount);
return true;
},
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;
},
_tickEconomy() {
const hours = HOURS_PER_DAY;
for (const civ of Array.from(this.budgets.keys())) {
const income = this.getCivHourlyIncome(civ) * hours;
const upkeep = this.getPlayerUpkeep(civ) * hours;
this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
}
},
_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;
this._addResearch(civ, scienceRate);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
_tickTraining() {
const completed = [];
for (const [cityId, queue] of this.training) {
const city = this.findCity(cityId);
if (!city) {
completed.push(cityId);
continue;
}
// One hour of production is spent on the head of the queue; any overflow
// carries into the next entry, so a fast producer can finish several
// cheap units or building levels in the same tick. Units and buildings
// share the queue and the same hourly production capacity.
let hoursLeft = 1;
while (hoursLeft > 0 && queue.length > 0) {
const entry = queue[0];
const proto = this._queueEntryProto(entry);
if (!proto) {
queue.shift();
continue;
}
const remaining = Math.max(entry.totalHours - entry.elapsedHours, 0);
if (hoursLeft >= remaining) {
hoursLeft -= remaining;
if (entry.kind === "building") {
this._completeBuilding(city, entry);
} else {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
}
queue.shift();
} else {
entry.elapsedHours += hoursLeft;
hoursLeft = 0;
}
}
if (queue.length === 0) completed.push(cityId);
}
for (const cityId of completed) this.training.delete(cityId);
},
// 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 cost was paid when the order
// was placed, so this only changes the city and invalidates the GDP cache.
_completeBuilding(city, entry) {
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
},
// Units resting inside one of their own cities recover a little health each
// in-game hour.
_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;
unit.hp = Math.min(unit.maxHp, unit.hp + CITY_GARRISON_HEAL_PER_HOUR);
}
},
};