- 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.
804 lines
30 KiB
JavaScript
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);
|
|
}
|
|
},
|
|
};
|