Roads are pre-generated between cities with a greedy geometric spanner and flatten unit movement cost. City improvements now only affect the tiles their region controls, with per-region tax collection and a Cities tab. The budget is a grouped, scrollable table with 25%/year tax and a 10%-of-GDP opening treasury. Also merged the tile info into the city panel, kept conflict and budget expansion state across snapshots, added join notifications, filled border corners, drew battle sides as stacks and made tab overflow scroll.
292 lines
9.8 KiB
JavaScript
292 lines
9.8 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 { serializeSet } from "./helpers.js";
|
|
|
|
export const serializationMethods = {
|
|
snapshot(viewerCiv = -1) {
|
|
return this.viewerSnapshot(this.serializeShared(), viewerCiv);
|
|
},
|
|
|
|
serializeShared() {
|
|
return {
|
|
seed: this.seed,
|
|
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),
|
|
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 })),
|
|
])
|
|
),
|
|
};
|
|
},
|
|
|
|
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,
|
|
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),
|
|
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,
|
|
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)),
|
|
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.
|
|
_serializeRoads() {
|
|
const result = [];
|
|
for (const k of this.roads) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y]);
|
|
}
|
|
return result;
|
|
},
|
|
|
|
// A sparse [x, y, cityId] list of every tile's region.
|
|
_serializeRegions() {
|
|
const result = [];
|
|
for (const [k, cityId] of this.tileRegion) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y, cityId]);
|
|
}
|
|
return result;
|
|
},
|
|
|
|
_serializePopulation() {
|
|
const result = [];
|
|
for (const [k, value] of this.tilePopulation) {
|
|
const coords = parseKey(k);
|
|
result.push([coords.x, coords.y, Math.round(value)]);
|
|
}
|
|
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));
|
|
},
|
|
|
|
// 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.modifier,
|
|
income: economy.income,
|
|
};
|
|
});
|
|
},
|
|
|
|
// 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];
|
|
},
|
|
};
|