Files
Battle-for-Tismo/shared/game_state/serialization.js
T
adrien d73ea4735d Grew the economy at a constant rate and forecast the budget
GDP per capita had no natural growth, so a nation's economy only moved
when population, buildings or war did. The summary stats also offered
inconsistent hover text: a native browser tooltip for popularity and none
at all for the budget.

- Every land tile now gains a constant 0.01% of the GDP per capita it
  opened with, once a day. Because the amount is fixed the relative growth
  rate tapers over the years. Each tile's opening figure is snapshotted
  once and shipped to the browser, so the tile panel and the GDP-per-capita
  map mode stay in step with the server.
- The population, GDP and GDP/capita tooltips show the relative growth:
  against 1 January 2000 through the first year, then year on year. The
  monthly captures now keep a rolling thirteen months for the baseline.
- Every summary stat explains itself through the same custom bubble.
  Popularity's native title moved onto it, and Budget forecasts the next
  calendar month at the current hourly net, as a compact figure such as
  "+€3.28B/mo" (hoursInNextMonth follows the real calendar).
- The tile GDP breakdown shows gains with a plus sign (natural growth,
  ports) rather than the old minus.

Added tests for the constant daily gain, the falling relative rate, the
first-year and year-on-year baselines, the snapshot baseline, the budget
forecast and the calendar month length.
2026-09-21 12:27:11 +02:00

678 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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());
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;
}
// 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;
}