// 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, 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, 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); 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 })); }, // 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; 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 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 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; }