The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
969 lines
39 KiB
JavaScript
969 lines
39 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();
|
||
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,
|
||
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: this._serializeCities(),
|
||
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),
|
||
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(),
|
||
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: hashCityStats(cityStats),
|
||
visible: this._visibleVersion,
|
||
productionBaseline: this._productionBaselineVersion,
|
||
resourceGraph: this._resourceVersion,
|
||
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);
|
||
// 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);
|
||
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;
|
||
const path = rawPath.map((coords) => [coords.x, coords.y]);
|
||
const segmentHours = [];
|
||
for (let i = 1; i < rawPath.length; i++) {
|
||
segmentHours.push(this._tileTravelHours(unit, rawPath[i]));
|
||
}
|
||
let stepHours = 0;
|
||
if (pathIndex + 1 < rawPath.length) {
|
||
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]);
|
||
}
|
||
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;
|
||
},
|
||
|
||
_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);
|
||
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: this.getRegionResourceInfo(city),
|
||
// 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 }));
|
||
},
|
||
|
||
// 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;
|
||
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,
|
||
};
|
||
});
|
||
},
|
||
|
||
// 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 the serialised city stats. Comparing it lets
|
||
// the server treat cityStats like the other versioned collections without
|
||
// having to hook every economy-affecting mutation (pillage, battle scars,
|
||
// approval drift, research) individually.
|
||
function hashCityStats(stats) {
|
||
const text = JSON.stringify(stats);
|
||
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;
|
||
}
|