// 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 { serializeSet } from "./helpers.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 GDP modifiers to the city that develops it. regions: this._serializeRegions(), population: this._serializePopulation(), // Every land tile's opening GDP per capita, the constant natural growth // is measured from. Static, so it travels once and is then delta-dropped. gdpBaseline: this._serializeGdpBaseline(), // Sparse per-tile marks of war, so the browser can explain a tile's // GDP per capita without shipping the whole breakdown for every tile. tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty), tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit), 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(), // 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, gdpBaseline: this._gdpBaselineVersion, }, }; }, 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); 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, breakdown: null, }; } 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, budget: this.getBudget(viewerCiv), upkeep: this.getPlayerUpkeep(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), // How far the headline figures have grown, against January 2000 in the // first year and year-on-year afterwards. growth: this._growthRates(viewerCiv), }; }, _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 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, ]); } 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 GDP per capita. It // never changes, so it is memoised and the delta wire drops it after the first // snapshot a peer receives. _serializeGdpBaseline() { if (this._serializedGdpBaselineCache) return this._serializedGdpBaselineCache; const result = []; if (this.tileGdpBaseline) { for (const [k, value] of this.tileGdpBaseline) { const coords = parseKey(k); result.push([coords.x, coords.y, Math.round(value)]); } } this._serializedGdpBaselineCache = result; return result; }, // 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); return { id: city.id, civ: city.civ, name: city.name, isCapital: city.isCapital, population: Math.round(economy.population), gdp: economy.gdp, base: economy.base, modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0, approval: economy.approval, income: economy.income, // The region's own income and building upkeep, for the city budget tab. budget: this.cityBudgetBreakdown(city, economy), // 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]; }, // --------------------------------------------------------- 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 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; }