Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
860 lines
34 KiB
JavaScript
860 lines
34 KiB
JavaScript
// Serialisation: turning the live model into the snapshots the server ships to
|
||
// each viewer, with fog-of-war applied per civilisation.
|
||
|
||
import { 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 { serializeSet } from "./helpers.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(),
|
||
// 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(),
|
||
// 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 military improvements each nation has under construction, so the
|
||
// tile panel can show a progress bar like a city's production.
|
||
tileWorks: this._serializeTileWorks(),
|
||
// 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,
|
||
},
|
||
};
|
||
},
|
||
|
||
viewerSnapshot(shared, viewerCiv, stats = null) {
|
||
const result = { ...shared };
|
||
result.viewer = viewerCiv;
|
||
result.viewerStats = stats || this.viewerStats(viewerCiv);
|
||
result.explored = this._cachedExplored(viewerCiv);
|
||
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
|
||
// 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,
|
||
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),
|
||
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,
|
||
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),
|
||
]);
|
||
}
|
||
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 tile improvements under construction, one array per nation, each entry
|
||
// { coords: [x, y], id, elapsedHours, totalHours }.
|
||
_serializeTileWorks() {
|
||
const result = [];
|
||
for (let i = 0; i < this.civilisations.length; i++) {
|
||
const queue = this.tileWorks.get(i) || [];
|
||
result.push(
|
||
queue.map((work) => ({
|
||
coords: [work.coords.x, work.coords.y],
|
||
id: work.id,
|
||
elapsedHours: work.elapsedHours,
|
||
totalHours: work.totalHours,
|
||
}))
|
||
);
|
||
}
|
||
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,
|
||
};
|
||
});
|
||
},
|
||
|
||
// 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] nodes and
|
||
// [fromX, fromY, toX, toY, resource, amount] links. A spectator sees none.
|
||
_serializeResourceGraph(viewerCiv) {
|
||
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [] };
|
||
const nodes = [];
|
||
for (const [k] of this.resourceStock) {
|
||
const coords = parseKey(k);
|
||
const node = this._resourceNodeAt(coords);
|
||
if (!node || node.civ !== viewerCiv) continue;
|
||
nodes.push([coords.x, coords.y, node.kind === "city" ? 1 : 0]);
|
||
}
|
||
const links = [];
|
||
for (const link of this.resourceLinks || []) {
|
||
const to = link.to;
|
||
if (this.civAt({ x: to[0], y: to[1] }) !== viewerCiv) continue;
|
||
links.push([
|
||
link.from[0],
|
||
link.from[1],
|
||
link.to[0],
|
||
link.to[1],
|
||
link.resource,
|
||
Math.round(link.amount * 1000) / 1000,
|
||
]);
|
||
}
|
||
return { nodes, links };
|
||
},
|
||
|
||
// --------------------------------------------------------- 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;
|
||
},
|
||
|
||
// 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;
|
||
}
|