506 lines
18 KiB
JavaScript
506 lines
18 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 { serializeSet } from "./helpers.js";
|
||
|
||
export const serializationMethods = {
|
||
snapshot(viewerCiv = -1) {
|
||
return this.viewerSnapshot(this.serializeShared(), viewerCiv);
|
||
},
|
||
|
||
serializeShared() {
|
||
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].
|
||
roads: this._serializeRoads(),
|
||
// 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(),
|
||
// 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: this._serializeCityStats(),
|
||
budgets: this._serializeCivValues(this.budgets),
|
||
research: this._serializeCivValues(this.research),
|
||
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(),
|
||
diplomacy: this._serializeDiplomacy(),
|
||
conflicts: this._serializeConflicts(),
|
||
news: this._serializeNews(),
|
||
battles: this._serializeBattles(),
|
||
training: Object.fromEntries(
|
||
Array.from(this.training.entries()).map(([cityId, queue]) => [
|
||
cityId,
|
||
queue.map((entry) => ({ ...entry })),
|
||
])
|
||
),
|
||
// 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,
|
||
},
|
||
};
|
||
},
|
||
|
||
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());
|
||
return result;
|
||
},
|
||
|
||
viewerStats(viewerCiv) {
|
||
if (viewerCiv < 0) {
|
||
return {
|
||
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
|
||
upkeep: 0, research: 0, culture: 0, government: 0,
|
||
approval: 0.5, cultureImpact: 0, ethnicMakeup: [],
|
||
breakdown: null,
|
||
};
|
||
}
|
||
const headline = this._civHeadline(viewerCiv);
|
||
return {
|
||
...headline,
|
||
budget: this.getBudget(viewerCiv),
|
||
upkeep: this.getPlayerUpkeep(viewerCiv),
|
||
research: this.getResearch(viewerCiv),
|
||
culture: this.getCulture(viewerCiv),
|
||
government: this.getGovernment(viewerCiv),
|
||
approval: this.getCivApproval(viewerCiv),
|
||
cultureImpact: this.getCulturalImpact(viewerCiv),
|
||
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
|
||
breakdown: this.budgetBreakdown(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 sparse [x, y] list of every road tile. Roads never change once generated,
|
||
// so the list is serialized once.
|
||
_serializeRoads() {
|
||
if (this._serializedRoadsCache) return this._serializedRoadsCache;
|
||
const result = [];
|
||
for (const k of this.roads) {
|
||
const coords = parseKey(k);
|
||
result.push([coords.x, coords.y]);
|
||
}
|
||
this._serializedRoadsCache = result;
|
||
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 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),
|
||
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),
|
||
// 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;
|
||
},
|
||
|
||
_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;
|
||
},
|
||
|
||
// 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._weightedApproval(this._territoryByCiv.get(a) || [], b), 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.
|
||
_serializeTileEthnicity() {
|
||
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]);
|
||
}
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
}
|