The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days. Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates. Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
1057 lines
42 KiB
JavaScript
1057 lines
42 KiB
JavaScript
// Serialisation: turning the live model into the snapshots the server ships to
|
||
// each viewer, with fog-of-war applied per civilisation.
|
||
|
||
import { key, parseKey } from "../hex.js";
|
||
import { OPINION } from "../data/politics.js";
|
||
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
|
||
import { RESOURCE_IDS } from "../data/resources.js";
|
||
import { HOURS_PER_DAY } from "./constants.js";
|
||
|
||
export const serializationMethods = {
|
||
snapshot(viewerCiv = -1) {
|
||
return this.viewerSnapshot(this.serializeShared(), viewerCiv);
|
||
},
|
||
|
||
serializeShared() {
|
||
// Built once and shared by every viewer. Its content hash doubles as the
|
||
// version that lets the server omit it from a delta when nothing moved, so
|
||
// a city panel is not re-sent on every 10 Hz broadcast.
|
||
const cityStats = this._serializeCityStats();
|
||
const cities = this._serializeCities();
|
||
return {
|
||
seed: this.seed,
|
||
// The world-generation config the seed was generated with. The browser
|
||
// rebuilds the same terrain from it, so a game on a non-default map size
|
||
// or shape is rendered correctly instead of falling back to the default.
|
||
mapConfig: this.mapConfig,
|
||
// A local test game where the client offers free, instant orders.
|
||
testing: !!this.testing,
|
||
// Which economy model the game runs ("simple" or "hard"), so a joiner
|
||
// renders the matching UI.
|
||
economyModel: this.economyModel,
|
||
totalHours: this.totalHours,
|
||
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
|
||
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
|
||
buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })),
|
||
governments: this.governments.map((g) => ({ id: g.id, name: g.name })),
|
||
technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })),
|
||
cities: cities,
|
||
units: this._serializeUnits(),
|
||
territory: this._serializedTerritory,
|
||
// The pre-generated road tiles, as [x, y], and the player-built railway
|
||
// tiles, so the browser can draw the transport network. Roads change when
|
||
// a player builds or replaces one, so both are memoised against the
|
||
// improvement version.
|
||
roads: this._serializeTransport(this.roads, "_serializedRoadsCache"),
|
||
railways: this._serializeTransport(this.railways, "_serializedRailwaysCache"),
|
||
// War-time tile works: trenches as [x, y], military improvements as
|
||
// [x, y, id, owner, hp].
|
||
trenches: this._serializeTrenches(),
|
||
tileImprovements: this._serializeTileImprovements(),
|
||
// Each land tile's region: [x, y, cityId], so the browser attributes a
|
||
// tile's production modifiers to the city that develops it.
|
||
regions: this._serializeRegions(),
|
||
population: this._serializePopulation(),
|
||
// Every land tile's opening production per head, the constant natural
|
||
// growth is measured from. Static, so it travels once and is then dropped.
|
||
productionBaseline: this._serializeProductionBaseline(),
|
||
// The production per head each region starts from, before the tile
|
||
// modifiers. The browser mirrors the server's tile breakdown from it.
|
||
regionProductionBase: this._serializeRegionProductionBase(),
|
||
// Sparse per-tile marks of war, so the browser can explain a tile's
|
||
// production per head without shipping the whole breakdown for every tile.
|
||
tileProductionPenalties: this._serializeTileValues(this.tileProductionPenalty),
|
||
tileBattleProductionDeficits: this._serializeTileValues(this.tileBattleProductionDeficit),
|
||
// Tiles a spy has crippled, with the hour each block ends.
|
||
tileSabotage: this._serializeSabotage(),
|
||
civStats: this._serializeCivStats(),
|
||
cityStats: cityStats,
|
||
budgets: this._serializeCivValues(this.budgets),
|
||
culture: this._serializeCivValues(this.culture),
|
||
// Each nation's share of the world's cultural output (zero-sum) and the
|
||
// people's approval of every government.
|
||
cultureImpact: this._serializeCultureImpact(),
|
||
approval: this._serializeApproval(),
|
||
// The ethnic makeup of every populated tile and the policies and
|
||
// propaganda campaigns in play.
|
||
tileEthnicity: this._serializeTileEthnicity(),
|
||
policies: this._serializePolicies(),
|
||
campaigns: this._serializeCampaigns(),
|
||
// The people who moved in and out of each country during the last day.
|
||
migrations: this._serializeMigrations(),
|
||
// The city-to-city migration over the last month, for the population
|
||
// map's flow arrows.
|
||
migrationGraph: this._serializeMigrationGraph(),
|
||
// Every population's opinion of every other population and government,
|
||
// so the client can show and target the propaganda.
|
||
opinions: {
|
||
ethnic: this._serializeOpinionMatrix(this.ethnicOpinions),
|
||
government: this._serializeGovOpinionMatrix(),
|
||
},
|
||
government: this._serializeCivValues(this.government),
|
||
researched: this._serializeResearched(),
|
||
// Focused research: the technology each nation is working on, the points
|
||
// committed to every technology it has started, and how many levels of
|
||
// each repeatable it has.
|
||
focusTechnology: this._serializeCivValues(this.focusTechnology),
|
||
techProgress: this._serializeTechProgress(),
|
||
repeatCounts: this._serializeRepeatCounts(),
|
||
diplomacy: this._serializeDiplomacy(),
|
||
conflicts: this._serializeConflicts(),
|
||
// Diplomatic treaties: offers awaiting an answer, the agreements in
|
||
// force, and the ones that ended.
|
||
treaties: this._serializeTreaties(),
|
||
treatyProposals: this._serializeTreatyProposals(),
|
||
treatyHistory: this._serializeTreatyHistory(),
|
||
news: this._serializeNews(),
|
||
battles: this._serializeBattles(),
|
||
// The global resource market: today's price and the supply and demand
|
||
// that moved it, for the resource panel.
|
||
resourceMarket: this._serializeResourceMarket(),
|
||
// The daily world-price index series per commodity, for the central
|
||
// bank's chart, and the monthly/yearly inflation each one shows.
|
||
resourcePriceHistory: this._serializeResourcePriceHistory(),
|
||
resourceInflation: this._serializeResourceInflation(),
|
||
// Every nation's imports and exports of each resource over the last day.
|
||
resourceTrade: this._serializeResourceTrade(),
|
||
// Each nation's indicators a month ago, for the summary change arrows;
|
||
// empty during January 2000, which has no past month.
|
||
monthly: this.monthlyStats || [],
|
||
training: Object.fromEntries(
|
||
Array.from(this.training.entries()).map(([cityId, queue]) => [
|
||
cityId,
|
||
queue.map((entry) => ({ ...entry })),
|
||
])
|
||
),
|
||
// The tile improvements each nation has under construction, so the tile
|
||
// panel can show the node gathering materials and building.
|
||
constructionSites: this._serializeConstructionSites(),
|
||
// Which of the large per-tile collections changed since the last
|
||
// broadcast, so a viewer can rebuild only those and the server can omit
|
||
// the rest from the wire (see GameServer._broadcastState).
|
||
versions: {
|
||
territory: this._territoryVersion,
|
||
regions: this._regionVersion,
|
||
population: this._populationVersion,
|
||
ethnicity: this._ethnicityVersion,
|
||
improvements: this._improvementVersion,
|
||
warfare: this._tileImprovementVersion,
|
||
cityStats: hashSerialized(cityStats),
|
||
cityList: hashSerialized(cities),
|
||
visible: this._visibleVersion,
|
||
productionBaseline: this._productionBaselineVersion,
|
||
resourceGraph: this._resourceVersion,
|
||
market: this._marketVersion || 0,
|
||
migrationGraph: this._migrationFlowVersion,
|
||
},
|
||
};
|
||
},
|
||
|
||
viewerSnapshot(shared, viewerCiv, stats = null) {
|
||
const result = { ...shared };
|
||
result.viewer = viewerCiv;
|
||
result.viewerStats = stats || this.viewerStats(viewerCiv);
|
||
result.explored = this._cachedExplored(viewerCiv);
|
||
result.visible = this._cachedVisible(viewerCiv);
|
||
// Enemy submarines stay off the map until one of the viewer's naval units
|
||
// is on or beside them.
|
||
const hidden = this._hiddenEnemyUnits(viewerCiv);
|
||
if (hidden.size > 0) {
|
||
result.units = (shared.units || []).filter((unit) => !hidden.has(unit.id));
|
||
}
|
||
// What a city is producing is its owner's business: every other viewer sees
|
||
// an empty queue, so neither the map bar nor the city panel can reveal
|
||
// whether a foreign city is busy.
|
||
result.training = trainingForViewer(shared.training, shared.cities, viewerCiv);
|
||
// The delivery graph is the viewer's own network: its storage nodes and the
|
||
// legs that carried goods into its cities. Only the economic map reveals it.
|
||
result.resourceGraph = this._serializeResourceGraph(viewerCiv);
|
||
// The spy reports brought home for this viewer. The chat log they are drawn
|
||
// from stays on the server and never ships.
|
||
result.spyReports = (this.spyReports && this.spyReports.get(viewerCiv)) || [];
|
||
// Satellites are few and their launches are public knowledge.
|
||
result.satellites = (this.satellites || []).map((satellite) => ({
|
||
civ: satellite.civ,
|
||
launchedHours: satellite.launchedHours,
|
||
nextRevealHour: satellite.nextRevealHour,
|
||
}));
|
||
return result;
|
||
},
|
||
|
||
viewerStats(viewerCiv) {
|
||
if (viewerCiv < 0) {
|
||
return {
|
||
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
|
||
upkeep: 0, researchRate: 0, culture: 0, government: 0,
|
||
approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
|
||
constructionSpeed: 0, railSpeed: 0,
|
||
resources: null,
|
||
breakdown: null,
|
||
currencyHoldings: null,
|
||
resourceAlerts: [],
|
||
taxes: null,
|
||
taxTake: { sales: 0, export: 0, import: 0 },
|
||
commodityInflation: 0,
|
||
privateBuildingCash: 0,
|
||
privateBuildingDebt: 0,
|
||
productionBuildings: [],
|
||
};
|
||
}
|
||
const headline = this._civHeadline(viewerCiv);
|
||
const modifiers = this.getCivModifiers(viewerCiv);
|
||
return {
|
||
...headline,
|
||
// Repeatable research the browser mirrors: construction and rail speed.
|
||
constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
|
||
railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
|
||
// The nation's stores, daily needs and production, plus today's prices.
|
||
resources: this.getCivResourceSummary(viewerCiv),
|
||
// The day's notices that the viewer's own producing buildings went short
|
||
// of a material or of grid power.
|
||
resourceAlerts: this._resourceAlertsFor(viewerCiv),
|
||
budget: this.getBudget(viewerCiv),
|
||
// The upkeep figure the HUD nets against income: propaganda money plus
|
||
// the market value of the materials buildings, units and works wear out.
|
||
upkeep: this.getPlayerUpkeep(viewerCiv) +
|
||
this._upkeepCost(this.getCivUpkeepResources(viewerCiv)),
|
||
// The research this nation creates each hour, all of which flows into the
|
||
// focused technology. There is no stock of points.
|
||
researchRate: modifiers[EFFECT_RESEARCH] || 0,
|
||
culture: this.getCulture(viewerCiv),
|
||
government: this.getGovernment(viewerCiv),
|
||
approval: this.getCivApproval(viewerCiv),
|
||
popularity: this.getCivPopularity(viewerCiv),
|
||
cultureImpact: this.getCulturalImpact(viewerCiv),
|
||
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
|
||
breakdown: this.budgetBreakdown(viewerCiv),
|
||
// The month's budget, plus the last two years of monthly digests, so the
|
||
// panel can report what has actually been spent rather than a rate.
|
||
budgetMonths: this._serializeBudgetMonths(viewerCiv),
|
||
// Money: the nation's currency, everyone else's, the exchange rates the
|
||
// viewer sees, the central bank's rate and reserves, and the aggregate
|
||
// private-sector cash its regions hold.
|
||
currency: this.currencyOf(viewerCiv),
|
||
currencies: this._serializeCurrencies(),
|
||
exchangeRates: this._serializeExchangeRates(viewerCiv),
|
||
centralBank: this._serializeCentralBank(viewerCiv),
|
||
privateCash: this.getPrivateSectorCash(viewerCiv),
|
||
// Production buildings are private-sector agents too, but a separate
|
||
// class from the regions: they keep their own cash, level and upgrade.
|
||
privateBuildingCash: this.getPrivateBuildingCash(viewerCiv),
|
||
privateBuildingDebt: this.getPrivateBuildingDebt(viewerCiv),
|
||
productionBuildings: this.getProductionAgents(viewerCiv),
|
||
// Where every unit of the viewer's currency is stored: its treasury, its
|
||
// regions, and foreign regions and central banks that hold it.
|
||
currencyHoldings: this.getCurrencyHoldings(viewerCiv),
|
||
governmentDebt: this.getGovernmentDebt(viewerCiv),
|
||
// How far the headline figures have grown, against January 2000 in the
|
||
// first year and year-on-year afterwards.
|
||
growth: this._growthRates(viewerCiv),
|
||
// The nation's trade-tax rates (with per-resource overrides) and what it
|
||
// collected over the last day, for the Taxes sub-tab.
|
||
taxes: this.getTaxConfigView(viewerCiv),
|
||
taxTake: this.getTaxTake(viewerCiv),
|
||
// The world commodity price move of the last day, weighted by what the
|
||
// world consumed, for the central bank's inflation indicator.
|
||
commodityInflation: this.commodityInflation || 0,
|
||
};
|
||
},
|
||
|
||
// The monthly budget reports, compacted for the wire: category ids replace
|
||
// the labels the client already knows from the data catalogue.
|
||
_serializeBudgetMonths(civ) {
|
||
return this.budgetMonthReports(civ).map((report) => ({
|
||
monthIndex: report.monthIndex,
|
||
isCurrent: report.isCurrent,
|
||
// The human-readable month, so the pager and the subtitle can name the
|
||
// month without rebuilding the calendar on the client.
|
||
label: report.label,
|
||
income: report.income,
|
||
taxes: report.taxes,
|
||
sales: report.sales,
|
||
totalCash: report.totalCash,
|
||
totalEconomic: report.totalEconomic,
|
||
net: report.net,
|
||
expenses: report.expenses.map(
|
||
(entry) => [entry.id, entry.cash, entry.economic, entry.buys]
|
||
),
|
||
resourceTrade: report.resourceTrade.map((entry) => [entry.id, entry.bought, entry.sold]),
|
||
}));
|
||
},
|
||
|
||
_serializeCities() {
|
||
return this.cities.map((city) => ({
|
||
id: city.id,
|
||
civ: city.civ,
|
||
coords: [city.coords.x, city.coords.y],
|
||
name: city.name,
|
||
isCapital: city.isCapital,
|
||
// Whether a port may be raised here, so the browser can grey out the
|
||
// order without regenerating the map.
|
||
coastal: this.isCoastalCity(city),
|
||
population: city.population,
|
||
improvements: city.improvements,
|
||
buildings: { ...city.buildings },
|
||
statuses: city.statuses || [],
|
||
}));
|
||
},
|
||
|
||
_serializeUnits() {
|
||
const battle = this._battleUnitIds();
|
||
return this.units.map((unit) => {
|
||
const rawPath = unit.path || [];
|
||
const pathIndex = unit.pathIndex;
|
||
// A ground path is a list of tiles; a flight is a list of world-pixel
|
||
// points. Both ship as [x, y] pairs and get their per-segment hours from
|
||
// the one shared calculator, so the browser need not know which it is.
|
||
const path = rawPath.map((point) => [point.x, point.y]);
|
||
const segmentHours = this._segmentHours(unit, rawPath);
|
||
let stepHours = 0;
|
||
if (pathIndex + 1 < rawPath.length) {
|
||
stepHours = this._segmentHours(unit, [rawPath[pathIndex], rawPath[pathIndex + 1]])[0] || 0;
|
||
}
|
||
return {
|
||
id: unit.id,
|
||
civ: unit.civ,
|
||
proto: unit.proto,
|
||
coords: [unit.coords.x, unit.coords.y],
|
||
hp: unit.hp,
|
||
maxHp: unit.maxHp,
|
||
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
|
||
// Aircraft carry their endurance so the client can show the fuel left.
|
||
airHours: unit.airHours,
|
||
airborne: !!unit.airborne,
|
||
homeCityId: unit.homeCityId,
|
||
// A land unit's carried food, in days. Zero means it is starving.
|
||
foodDays: unit.foodDays,
|
||
// A standing bombardment target (ground battery) or bomb-run target
|
||
// (aircraft), as [x, y], and the hour an aircraft may strike again.
|
||
strikeTarget: unit.strikeTarget
|
||
? [unit.strikeTarget.x, unit.strikeTarget.y]
|
||
: null,
|
||
strikeReadyHour: unit.strikeReadyHour || 0,
|
||
path,
|
||
pathIndex,
|
||
progressHours: unit.progressHours,
|
||
stepHours,
|
||
segmentHours,
|
||
waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]),
|
||
// A transport's cargo and any boarding still under way.
|
||
cargo: (unit.cargo || []).map((passenger) => ({ ...passenger })),
|
||
embarking: unit.embarking
|
||
? { hoursLeft: unit.embarking.hoursLeft, count: unit.embarking.ids.length }
|
||
: null,
|
||
};
|
||
});
|
||
},
|
||
|
||
// A sparse [x, y] list of every tile in `tiles`. The list is memoised against
|
||
// the improvement version: the pre-generated road network is laid once, and
|
||
// player-built roads and railways change it in step, so an unchanged network
|
||
// is not re-walked on every broadcast.
|
||
_serializeTransport(tiles, cacheField) {
|
||
const cached = this[cacheField];
|
||
if (cached && cached.version === this._improvementVersion) return cached.value;
|
||
const result = [];
|
||
for (const k of tiles) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y]);
|
||
}
|
||
this[cacheField] = { version: this._improvementVersion, value: result };
|
||
return result;
|
||
},
|
||
|
||
_serializeTrenches() {
|
||
const result = [];
|
||
for (const k of this.trenches) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y]);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// Tiles crippled by sabotage, as [x, y, untilHour]. The map only ever holds
|
||
// live blocks (`_tickSabotage` drops them as they lapse), so the list is tiny.
|
||
_serializeSabotage() {
|
||
const result = [];
|
||
for (const [k, until] of this.tileSabotage) {
|
||
if (until <= this.totalHours) continue;
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y, until]);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
_serializeTileImprovements() {
|
||
const result = [];
|
||
for (const [k, id] of this.tileImprovements) {
|
||
const coords = parseKey(k);
|
||
result.push([
|
||
coords.x,
|
||
coords.y,
|
||
id,
|
||
this.tileImprovementOwner.get(k) ?? -1,
|
||
this.tileImprovementHp.get(k) || 0,
|
||
this.resourceBuildingLevel(k),
|
||
this.improvementCash.get(k) || 0,
|
||
]);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// A sparse [x, y, cityId] list of every tile's region, memoised against the
|
||
// region version so an unchanged world is not re-walked on every broadcast.
|
||
_serializeRegions() {
|
||
if (this._serializedRegionsCache &&
|
||
this._serializedRegionsCache.version === this._regionVersion) {
|
||
return this._serializedRegionsCache.value;
|
||
}
|
||
const result = [];
|
||
for (const [k, cityId] of this.tileRegion) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y, cityId]);
|
||
}
|
||
this._serializedRegionsCache = { version: this._regionVersion, value: result };
|
||
return result;
|
||
},
|
||
|
||
_serializePopulation() {
|
||
if (this._serializedPopulationCache &&
|
||
this._serializedPopulationCache.version === this._populationVersion) {
|
||
return this._serializedPopulationCache.value;
|
||
}
|
||
const result = [];
|
||
for (const [k, value] of this.tilePopulation) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y, Math.round(value)]);
|
||
}
|
||
this._serializedPopulationCache = { version: this._populationVersion, value: result };
|
||
return result;
|
||
},
|
||
|
||
// A sparse [x, y, value] list of every land tile's opening production per
|
||
// head. It never changes, so it is memoised and the delta wire drops it after
|
||
// the first snapshot a peer receives.
|
||
_serializeProductionBaseline() {
|
||
if (this._serializedProductionBaselineCache) return this._serializedProductionBaselineCache;
|
||
const result = [];
|
||
if (this.tileProductionBaseline) {
|
||
for (const [k, value] of this.tileProductionBaseline) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y, Math.round(value)]);
|
||
}
|
||
}
|
||
this._serializedProductionBaselineCache = result;
|
||
return result;
|
||
},
|
||
|
||
// Each region's production per head, before its tile modifiers, as
|
||
// [cityId, value]. Small and world-wide, so every viewer gets it and the tile
|
||
// panel can explain a tile's figure exactly as the server derived it.
|
||
_serializeRegionProductionBase() {
|
||
return this.cities.map((city) => [
|
||
city.id,
|
||
roundTo(this._regionProductionPerCapita(city), 4),
|
||
]);
|
||
},
|
||
|
||
// A sparse [x, y, value] list for a per-tile map, used for the war marks.
|
||
_serializeTileValues(values) {
|
||
const result = [];
|
||
for (const [k, value] of values) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y, value]);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// One entry per civilisation with its headline economy figures, so the
|
||
// diplomacy panel can show every nation's standing without a second lookup.
|
||
_serializeCivStats() {
|
||
return this.civilisations.map((_, index) => ({
|
||
...this._civHeadline(index),
|
||
approval: this.getCivApproval(index),
|
||
popularity: this.getCivPopularity(index),
|
||
cultureImpact: this.getCulturalImpact(index),
|
||
}));
|
||
},
|
||
|
||
// One entry per city with its region's population, GDP and tax, so the city
|
||
// government panel can show what each region collects.
|
||
_serializeCityStats() {
|
||
return this.cities.map((city) => {
|
||
const economy = this.getCityEconomy(city);
|
||
const taxes = this.getCityTaxTake(city);
|
||
// The simple economy has one national pool rather than per-region stores,
|
||
// so the region figure its Resources tab needs is its spare power, which
|
||
// drives the build clock. The hard economy reads its own stores instead.
|
||
const resources = this.getRegionResourceInfo(city);
|
||
if (this.isSimpleEconomy()) resources.energy = this.regionAvailableEnergy(city);
|
||
return {
|
||
id: city.id,
|
||
civ: city.civ,
|
||
name: city.name,
|
||
isCapital: city.isCapital,
|
||
population: Math.round(economy.population),
|
||
gdp: economy.gdp,
|
||
approval: economy.approval,
|
||
// The trade taxes the region generated over the last day, in its
|
||
// currency, and their hourly average.
|
||
taxes,
|
||
income: (taxes.sales + taxes.export + taxes.import) / HOURS_PER_DAY,
|
||
// The region's own income and building upkeep, for the city budget tab.
|
||
budget: this.cityBudgetBreakdown(city, economy),
|
||
// The private-sector cash the region holds, a basket of currencies, and
|
||
// what it owes its central bank.
|
||
cash: this._serializeRegionCash(city),
|
||
cashValue: this.getRegionCashValue(city),
|
||
debt: this.getRegionDebt(city),
|
||
// What the region's people went short of over the last day, so the
|
||
// People tab can report it, and whether they are starving.
|
||
shortage: this.getRegionShortages(city),
|
||
famine: this.isFamine(city),
|
||
// The region's reserves, their market value and the private sector's
|
||
// daily consumption, for the Resources tab.
|
||
resources,
|
||
// The ethnicity of the region's people, for the circle graph.
|
||
ethnicMakeup: this.getCityEthnicMakeup(city),
|
||
};
|
||
});
|
||
},
|
||
|
||
// Population, GDP and GDP per capita for one civilisation: the headline
|
||
// figures the diplomacy panel and the viewer's own stats both show.
|
||
_civHeadline(civ) {
|
||
const aggregates = this._playerAggregates(civ);
|
||
const population = Math.round(aggregates.population);
|
||
const gdp = aggregates.gdp;
|
||
return { population, gdp, gdpPerCapita: population > 0 ? gdp / population : 0 };
|
||
},
|
||
|
||
_serializeCivValues(values) {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) {
|
||
result.push(values.has(i) ? values.get(i) : 0);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// The points each nation has committed to every technology it has started,
|
||
// as { technologyIndex: points } objects. Work is never lost when the focus
|
||
// changes, so every started technology travels in the snapshot.
|
||
_serializeTechProgress() {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) {
|
||
const map = this.techProgress.get(i);
|
||
const record = {};
|
||
if (map) {
|
||
for (const [index, points] of map) {
|
||
if (points > 0) record[index] = points;
|
||
}
|
||
}
|
||
result.push(record);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// The construction sites under way, one array per nation, each entry carrying
|
||
// the phase, the locked prices, what the site still needs and has bought, and
|
||
// the route to the allied city it hauls from.
|
||
_serializeConstructionSites() {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) result.push([]);
|
||
for (const site of this.constructionSites.values()) {
|
||
const list = result[site.civ];
|
||
if (!list) continue;
|
||
list.push({
|
||
coords: [site.coords.x, site.coords.y],
|
||
id: site.id,
|
||
kind: site.kind,
|
||
targetLevel: site.targetLevel,
|
||
private: !!site.private,
|
||
phase: site.phase,
|
||
stalled: site.stalled,
|
||
budget: site.budget,
|
||
// Materials no reachable source can supply: the one case the panel
|
||
// keeps a warning for, since no budget can unstall the work.
|
||
unreachable: site.stalled ? this._siteUnreachableMaterials(site) : [],
|
||
prices: { ...site.prices },
|
||
needed: { ...site.needed },
|
||
bought: { ...site.bought },
|
||
elapsedHours: site.elapsedHours,
|
||
totalHours: site.totalHours,
|
||
cityId: site.cityId,
|
||
path: site.path.map((cell) => [cell[0], cell[1]]),
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
_serializeResearched() {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) {
|
||
const set = this.researched.get(i) || new Set();
|
||
const indices = Array.from(set).sort((a, b) => a - b);
|
||
result.push(indices);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// How many times each nation has researched every repeatable technology, as
|
||
// { technologyId: count } objects, skipping the unused ones.
|
||
_serializeRepeatCounts() {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) {
|
||
const counts = this.repeatCounts.get(i);
|
||
const record = {};
|
||
if (counts) {
|
||
for (const [id, count] of counts) {
|
||
if (count > 0) record[id] = count;
|
||
}
|
||
}
|
||
result.push(record);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// Every unordered pair with its current status. With a handful of
|
||
// civilisations the whole matrix is tiny, and it lets the client show the
|
||
// status of every nation without a second lookup.
|
||
_serializeDiplomacy() {
|
||
const result = [];
|
||
for (let a = 0; a < this.civilisations.length; a++) {
|
||
for (let b = a + 1; b < this.civilisations.length; b++) {
|
||
result.push([a, b, this.getRelation(a, b)]);
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// Every ongoing war with its start date and the casualties each side has
|
||
// suffered, broken down into military and civilian dead.
|
||
_serializeConflicts() {
|
||
const result = [];
|
||
for (const conflict of this.conflicts.values()) {
|
||
result.push({
|
||
a: conflict.a,
|
||
b: conflict.b,
|
||
started: conflict.startDate,
|
||
startHours: conflict.startHours,
|
||
sides: [conflict.a, conflict.b].map((civ) => ({
|
||
civ,
|
||
military: conflict.military.get(civ) || 0,
|
||
civilians: Math.round(conflict.civilians.get(civ) || 0),
|
||
})),
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
_serializeNews() {
|
||
return this.news.map((entry) => ({ ...entry }));
|
||
},
|
||
|
||
// A treaty as it travels on the wire: the parties, the terms' payload, and
|
||
// the signing (and, in the history, ending) dates.
|
||
_serializeTreaty(treaty) {
|
||
return {
|
||
id: treaty.id,
|
||
type: treaty.type,
|
||
a: treaty.a,
|
||
b: treaty.b,
|
||
payload: treaty.payload ? JSON.parse(JSON.stringify(treaty.payload)) : {},
|
||
signedHours: treaty.signedHours,
|
||
signedDate: treaty.signedDate,
|
||
endedHours: treaty.endedHours,
|
||
endedDate: treaty.endedDate,
|
||
reason: treaty.reason,
|
||
};
|
||
},
|
||
|
||
_serializeTreaties() {
|
||
return this.treaties.map((treaty) => this._serializeTreaty(treaty));
|
||
},
|
||
|
||
_serializeTreatyHistory() {
|
||
return this.treatyHistory.map((treaty) => this._serializeTreaty(treaty));
|
||
},
|
||
|
||
_serializeTreatyProposals() {
|
||
return this.treatyProposals.map((proposal) => ({
|
||
id: proposal.id,
|
||
type: proposal.type,
|
||
from: proposal.from,
|
||
to: proposal.to,
|
||
payload: proposal.payload ? JSON.parse(JSON.stringify(proposal.payload)) : {},
|
||
proposedHours: proposal.proposedHours,
|
||
proposedDate: proposal.proposedDate,
|
||
}));
|
||
},
|
||
|
||
// One entry per battlefield: [x, y, defenderCiv, attackerCiv]. The tile's
|
||
// owner is the defender when present; otherwise the lowest-numbered civ on
|
||
// the tile defends. The browser uses this to draw the two stacks facing each
|
||
// other with the battle icon between them.
|
||
_serializeBattles() {
|
||
const result = [];
|
||
for (const battle of this._battleTiles()) {
|
||
const civs = Array.from(battle.civs).sort((a, b) => a - b);
|
||
const owner = this.civAt(battle.coords);
|
||
const defender = this._defenderCiv(battle, civs, owner);
|
||
const attacker = civs.find((civ) => civ !== defender);
|
||
if (attacker === undefined) continue;
|
||
result.push([battle.coords.x, battle.coords.y, defender, attacker]);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// The side that charged in is the attacker; the occupants defend. When that is
|
||
// ambiguous (a mutual charge, or a third party) fall back to the tile's owner
|
||
// and then to the lowest-numbered civ present.
|
||
_defenderCiv(battle, civs, owner) {
|
||
const invaders = new Set();
|
||
let hasDefender = false;
|
||
for (const unit of battle.units) {
|
||
if (unit.invader) invaders.add(unit.civ);
|
||
else hasDefender = true;
|
||
}
|
||
const defenders = civs.filter((civ) => !invaders.has(civ));
|
||
if (invaders.size > 0 && hasDefender && defenders.length > 0) {
|
||
if (defenders.length === 1) return defenders[0];
|
||
return defenders.includes(owner) ? owner : defenders[0];
|
||
}
|
||
return civs.includes(owner) ? owner : civs[0];
|
||
},
|
||
|
||
// -------------------------------------------------------- resources --
|
||
|
||
// Today's global price and the supply and demand behind it, one entry per
|
||
// resource. The figures are world-wide, so every viewer sees the same market.
|
||
_serializeResourceMarket() {
|
||
return RESOURCE_IDS.map((id) => {
|
||
const stats = this.resourceMarketStats ? this.resourceMarketStats.get(id) : null;
|
||
// The simple economy's price is derived from a shared world stock; the
|
||
// hard economy has no stock, so the field is null there. Energy is a flow
|
||
// and is never stocked.
|
||
const stock = this.marketStock ? this.marketStock.get(id) : undefined;
|
||
return {
|
||
id,
|
||
price: this.getResourcePrice(id),
|
||
// Yesterday's price, for the day-on-day arrows and the inflation index.
|
||
prevPrice: this.getResourcePrevPrice(id),
|
||
supply: stats ? stats.supply : 0,
|
||
demand: stats ? stats.demand : 0,
|
||
stock: stock === undefined ? null : stock,
|
||
};
|
||
});
|
||
},
|
||
|
||
// The daily world-price index of every commodity, [id, [index, ...]], oldest
|
||
// first, 100 at January 2000's opening price. The daily series rolls, so the
|
||
// array never exceeds the chart window; world-wide, so it travels once.
|
||
_serializeResourcePriceHistory() {
|
||
return RESOURCE_IDS.map((id) => {
|
||
const series = this.resourceDailyPrices ? this.resourceDailyPrices.get(id) || [] : [];
|
||
const monthly = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) || [] : [];
|
||
const base = monthly[0] > 0 ? monthly[0] : (series[0] > 0 ? series[0] : 1);
|
||
return [
|
||
id,
|
||
series.map((price) => roundTo((price / base) * 100, 3)),
|
||
];
|
||
});
|
||
},
|
||
|
||
// Each commodity's index (100 in January 2000) and its month-on-month and
|
||
// year-on-year inflation, for the central bank tab.
|
||
_serializeResourceInflation() {
|
||
return RESOURCE_IDS.map((id) => {
|
||
const info = this.resourceInflation(id);
|
||
return {
|
||
id,
|
||
index: roundTo(info.index, 3),
|
||
monthly: roundTo(info.monthly, 6),
|
||
yearly: roundTo(info.yearly, 6),
|
||
};
|
||
});
|
||
},
|
||
|
||
// Every nation's imports and exports of each resource over the last day, so
|
||
// the Resources tab can show a viewer's own trade and rank the world's major
|
||
// exporters and importers. Cross-nation deliveries only.
|
||
_serializeResourceTrade() {
|
||
return this.civilisations.map((_, civ) => {
|
||
const record = this.resourceTrade ? this.resourceTrade.get(civ) : null;
|
||
const imports = {};
|
||
const exports = {};
|
||
for (const id of RESOURCE_IDS) {
|
||
imports[id] = roundTo(record ? record.imports[id] || 0 : 0, 3);
|
||
exports[id] = roundTo(record ? record.exports[id] || 0 : 0, 3);
|
||
}
|
||
return { civ, imports, exports };
|
||
});
|
||
},
|
||
|
||
// The viewer's own storage nodes and delivery legs, as [x, y, isCity] nodes,
|
||
// [fromX, fromY, toX, toY, resource, amount] links and [fromX, fromY, toX,
|
||
// toY] edges. Links are the deliveries that actually ran over the last day,
|
||
// imports and exports alike; edges are every route the viewer's own nodes
|
||
// could trade over, so the map can draw the idle ones grey. A spectator sees
|
||
// none.
|
||
_serializeResourceGraph(viewerCiv) {
|
||
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [], edges: [] };
|
||
// The reachability behind the edges costs a BFS per city, and the trade
|
||
// network only moves on the resource day, so the whole graph is memoised
|
||
// against the resource version.
|
||
if (!this._resourceGraphCache) this._resourceGraphCache = new Map();
|
||
const cached = this._resourceGraphCache.get(viewerCiv);
|
||
if (cached && cached.version === this._resourceVersion) return cached.graph;
|
||
const graph = this._buildResourceGraph(viewerCiv);
|
||
this._resourceGraphCache.set(viewerCiv, { version: this._resourceVersion, graph });
|
||
return graph;
|
||
},
|
||
|
||
_buildResourceGraph(viewerCiv) {
|
||
const nodes = new Map();
|
||
const addNode = (coords, isCity) => {
|
||
const k = key(coords.x, coords.y);
|
||
if (!nodes.has(k)) nodes.set(k, [coords.x, coords.y, isCity ? 1 : 0]);
|
||
};
|
||
for (const [k] of this.resourceStock) {
|
||
const coords = parseKey(k);
|
||
const node = this._resourceNodeAt(coords);
|
||
if (!node || node.civ !== viewerCiv) continue;
|
||
addNode(coords, node.kind === "city");
|
||
}
|
||
const links = [];
|
||
for (const link of this.resourceLinks || []) {
|
||
const from = { x: link.from[0], y: link.from[1] };
|
||
const to = { x: link.to[0], y: link.to[1] };
|
||
const supplier = this._resourceNodeAt(from);
|
||
const supplierCiv = supplier ? supplier.civ : -1;
|
||
const buyerCiv = this.civAt(to);
|
||
// Imports into the viewer's cities and exports out of its nodes both
|
||
// belong on the graph.
|
||
if (supplierCiv !== viewerCiv && buyerCiv !== viewerCiv) continue;
|
||
addNode(from, !!(supplier && supplier.kind === "city"));
|
||
addNode(to, true);
|
||
links.push([
|
||
link.from[0],
|
||
link.from[1],
|
||
link.to[0],
|
||
link.to[1],
|
||
link.resource,
|
||
Math.round(link.amount * 1000) / 1000,
|
||
]);
|
||
}
|
||
return { nodes: Array.from(nodes.values()), links, edges: this._resourceCandidateEdges(viewerCiv) };
|
||
},
|
||
|
||
// --------------------------------------------------------- politics --
|
||
|
||
// Every nation's share of world culture, as a fraction per civilisation.
|
||
_serializeCultureImpact() {
|
||
return this.civilisations.map((_, index) =>
|
||
roundTo(this.getCulturalImpact(index), 4)
|
||
);
|
||
},
|
||
|
||
// approval[a][b] is the share of civilisation a's people who approve of
|
||
// civilisation b's government. The diagonal is each nation's own standing.
|
||
// The N×N matrix weights every nation's opinion over every tile it holds, so
|
||
// it is memoised against the politics version: snapshots are broadcast on
|
||
// every dirty frame but approval only moves when politics does.
|
||
_serializeApproval() {
|
||
if (this._approvalMatrixCache && this._approvalMatrixCache.version === this._politicsVersion) {
|
||
return this._approvalMatrixCache.value;
|
||
}
|
||
const result = [];
|
||
for (let a = 0; a < this.civilisations.length; a++) {
|
||
const row = [];
|
||
for (let b = 0; b < this.civilisations.length; b++) {
|
||
row.push(roundTo(this._weightedOpinions(this._territoryByCiv.get(a) || [], b).approval, 3));
|
||
}
|
||
result.push(row);
|
||
}
|
||
this._approvalMatrixCache = { version: this._politicsVersion, value: result };
|
||
return result;
|
||
},
|
||
|
||
// Sparse [x, y, [[ethnicity, share, trend], ...]] for every populated tile,
|
||
// where trend is the net migration over the last day. Memoised against the
|
||
// ethnicity version and the current migration delta store: the shares only
|
||
// move when politics does, so re-walking every populated tile on every 10 Hz
|
||
// broadcast was pure waste (the delta wire format drops it anyway).
|
||
_serializeTileEthnicity() {
|
||
const cache = this._serializedTileEthnicityCache;
|
||
if (cache &&
|
||
cache.version === this._ethnicityVersion &&
|
||
cache.deltas === this._migrationDeltas.tile) {
|
||
return cache.value;
|
||
}
|
||
const result = [];
|
||
for (const [k, shares] of this.tileEthnicity) {
|
||
const coords = parseKey(k);
|
||
const entries = [];
|
||
for (const [ethnicity, share] of shares) {
|
||
if (share < 0.005) continue;
|
||
entries.push([
|
||
ethnicity,
|
||
roundTo(share, 3),
|
||
Math.round(this._trendAt(this._migrationDeltas.tile, k, ethnicity)),
|
||
]);
|
||
}
|
||
if (entries.length === 0) continue;
|
||
result.push([coords.x, coords.y, entries]);
|
||
}
|
||
this._serializedTileEthnicityCache = {
|
||
version: this._ethnicityVersion,
|
||
deltas: this._migrationDeltas.tile,
|
||
value: result,
|
||
};
|
||
return result;
|
||
},
|
||
|
||
_serializePolicies() {
|
||
const result = [];
|
||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||
const record = this.policies.get(civ) || [];
|
||
result.push({
|
||
civ,
|
||
list: record.map((policy) => ({
|
||
id: policy.id,
|
||
type: policy.type,
|
||
ethnicity: policy.ethnicity,
|
||
})),
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
_serializeCampaigns() {
|
||
return this.campaigns.map((campaign) => ({
|
||
id: campaign.id,
|
||
civ: campaign.civ,
|
||
observer: campaign.observer,
|
||
targetKind: campaign.targetKind,
|
||
target: campaign.target,
|
||
direction: campaign.direction,
|
||
hourlyCost: campaign.hourlyCost,
|
||
startedHours: campaign.startedHours,
|
||
}));
|
||
},
|
||
|
||
// The last day's migration into and out of each country, with each flow as
|
||
// [ethnicity, people].
|
||
_serializeMigrations() {
|
||
const result = [];
|
||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||
const flow = this.migrations.get(civ);
|
||
result.push({
|
||
civ,
|
||
inbound: mapToEntries(flow ? flow.inbound : null),
|
||
outbound: mapToEntries(flow ? flow.outbound : null),
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// The city-to-city migration over the rolling month, as [fromX, fromY, toX,
|
||
// toY, people, fromCiv] links for the population map. The origin nation rides
|
||
// along so the client can flag the people on the move. World-wide, like the
|
||
// per-tile population the mode paints, and memoised against the flow version
|
||
// so an unchanged graph is not re-summed on every broadcast.
|
||
_serializeMigrationGraph() {
|
||
const cache = this._serializedMigrationGraphCache;
|
||
if (cache && cache.version === this._migrationFlowVersion) return cache.value;
|
||
const links = [];
|
||
for (const [flowKey, people] of this._migrationFlowTotals()) {
|
||
const amount = Math.round(people);
|
||
if (amount <= 0) continue;
|
||
const [fromId, toId] = flowKey.split(":").map(Number);
|
||
const from = this._cityById.get(fromId);
|
||
const to = this._cityById.get(toId);
|
||
if (!from || !to) continue;
|
||
links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount, from.civ]);
|
||
}
|
||
links.sort((a, b) => a[4] - b[4]);
|
||
const value = { links };
|
||
this._serializedMigrationGraphCache = { version: this._migrationFlowVersion, value };
|
||
return value;
|
||
},
|
||
|
||
// A dense observer x target matrix. A missing entry is the neutral baseline.
|
||
_serializeOpinionMatrix(store) {
|
||
const result = [];
|
||
for (let observer = 0; observer < this.civilisations.length; observer++) {
|
||
const inner = store.get(observer);
|
||
const row = [];
|
||
for (let target = 0; target < this.civilisations.length; target++) {
|
||
row.push(roundTo(inner && inner.has(target) ? inner.get(target) : OPINION.baseline, 3));
|
||
}
|
||
result.push(row);
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// The government matrix folds each war crime grudge into the base opinion.
|
||
_serializeGovOpinionMatrix() {
|
||
const result = [];
|
||
for (let observer = 0; observer < this.civilisations.length; observer++) {
|
||
const row = [];
|
||
for (let target = 0; target < this.civilisations.length; target++) {
|
||
row.push(roundTo(this.getGovOpinion(observer, target), 3));
|
||
}
|
||
result.push(row);
|
||
}
|
||
return result;
|
||
},
|
||
};
|
||
|
||
function roundTo(value, digits) {
|
||
const factor = Math.pow(10, digits);
|
||
return Math.round(value * factor) / factor;
|
||
}
|
||
|
||
// The production queues a viewer may see: only its own cities'. A spectator
|
||
// (viewer -1) sees none. City ids are object keys, so they arrive as strings.
|
||
function trainingForViewer(training, cities, viewerCiv) {
|
||
if (!training || viewerCiv < 0) return {};
|
||
const own = new Set();
|
||
for (const city of cities || []) {
|
||
if (city.civ === viewerCiv) own.add(city.id);
|
||
}
|
||
const filtered = {};
|
||
for (const [cityId, queue] of Object.entries(training)) {
|
||
if (own.has(Number(cityId))) filtered[cityId] = queue;
|
||
}
|
||
return filtered;
|
||
}
|
||
|
||
// A cheap, stable fingerprint of a serialised snapshot section. Comparing it
|
||
// lets the server treat a collection like the other versioned ones without
|
||
// having to hook every mutation that touches it -- for the city stats that is
|
||
// pillage, battle scars, approval drift and research; for the city list, a
|
||
// capture or a new building level.
|
||
function hashSerialized(value) {
|
||
const text = JSON.stringify(value);
|
||
let hash = 2166136261;
|
||
for (let i = 0; i < text.length; i++) {
|
||
hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
||
}
|
||
return `${text.length}:${(hash >>> 0).toString(36)}`;
|
||
}
|
||
|
||
// A Map<ethnicity, number> as sorted [ethnicity, roundedNumber] pairs, dropping
|
||
// zero entries.
|
||
function mapToEntries(map) {
|
||
if (!map) return [];
|
||
const result = [];
|
||
for (const [ethnicity, value] of map) {
|
||
const rounded = Math.round(value);
|
||
if (rounded === 0) continue;
|
||
result.push([ethnicity, rounded]);
|
||
}
|
||
result.sort((a, b) => b[1] - a[1]);
|
||
return result;
|
||
}
|