The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
// World generation and settlement placement: laying out capitals and cities,
|
|
// spawning their starting units, seeding population and budgets, and the small
|
|
// terrain predicates the rest of the model leans on.
|
|
|
|
import { ECONOMY } from "../data/economy.js";
|
|
import { RESOURCE_RULES } from "../data/resources.js";
|
|
import { transportImprovement } from "../data/improvements.js";
|
|
import { buildRoadNetwork } from "../roads.js";
|
|
import { MapTopology, key, parseKey } from "../hex.js";
|
|
import { MapGenerator } from "../map_generator.js";
|
|
import { TerrainStats } from "../terrain_stats.js";
|
|
import { Random } from "../rng.js";
|
|
|
|
// World generation is a pure function of (seed, mapConfig): the same inputs
|
|
// always produce the same terrain, city sites and road network. Caching the
|
|
// shape lets a new game with a known seed -- a server restart, or one test
|
|
// fixture after another -- skip regenerating the whole world every time. Only
|
|
// the immutable terrain pieces are shared; every mutable collection is rebuilt
|
|
// per GameState.
|
|
const WORLD_CACHE_LIMIT = 16;
|
|
const terrainCache = new Map();
|
|
const settlementCache = new Map();
|
|
|
|
function cachePut(cache, cacheKey, value) {
|
|
cache.set(cacheKey, value);
|
|
if (cache.size > WORLD_CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
}
|
|
|
|
function copyCoords(coords) {
|
|
return { x: coords.x, y: coords.y };
|
|
}
|
|
|
|
export const worldMethods = {
|
|
// Builds or reuses the topology, tiles and terrain stats for `this.seed` and
|
|
// `this.mapConfig`.
|
|
_buildTerrain() {
|
|
const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}`;
|
|
const cached = terrainCache.get(cacheKey);
|
|
if (cached) {
|
|
this.topology = cached.topology;
|
|
this.tiles = cached.tiles;
|
|
this.landCells = cached.landCells;
|
|
this.terrainStats = cached.terrainStats;
|
|
this._generator = cached.generator;
|
|
this._minMovementCost = cached.minMovementCost;
|
|
this._maxStepLength = cached.maxStepLength;
|
|
return;
|
|
}
|
|
this.topology = new MapTopology(
|
|
this.mapConfig.mapSize,
|
|
this.mapConfig.topology === "cylindrical"
|
|
);
|
|
this._generator = new MapGenerator(this.mapConfig, this.topology);
|
|
this._generator.generate(this.seed);
|
|
this.tiles = this._generator.tiles;
|
|
this.landCells = this._generator.landCells;
|
|
this.terrainStats = new TerrainStats(this.tiles);
|
|
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
|
|
this._maxStepLength = this._computeMaxStepLength();
|
|
cachePut(terrainCache, cacheKey, {
|
|
topology: this.topology,
|
|
tiles: this.tiles,
|
|
landCells: this.landCells,
|
|
terrainStats: this.terrainStats,
|
|
generator: this._generator,
|
|
minMovementCost: this._minMovementCost,
|
|
maxStepLength: this._maxStepLength,
|
|
});
|
|
},
|
|
|
|
_placeSettlements() {
|
|
// City sites and the road network depend only on the seed, the map config
|
|
// and how many nations are playing, so they are cached alongside the
|
|
// terrain and reused when a known world is started again.
|
|
const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}|${this.civilisations.length}`;
|
|
const cached = settlementCache.get(cacheKey);
|
|
const capitals = cached
|
|
? cached.capitals.map(copyCoords)
|
|
: this._placeCapitals(this.civilisations.length);
|
|
const sites = cached
|
|
? cached.sites.map((site) => ({ ...site, coords: copyCoords(site.coords) }))
|
|
: this._placeCities(capitals);
|
|
for (const site of sites) this._spawnCity(site);
|
|
this._rebuildCityIndex();
|
|
this._seedCityPorts();
|
|
this._generateRoads(cached ? cached.roads : null);
|
|
if (!cached) {
|
|
cachePut(settlementCache, cacheKey, {
|
|
capitals: capitals.map(copyCoords),
|
|
sites: sites.map((site) => ({ ...site, coords: copyCoords(site.coords) })),
|
|
roads: new Set(this.roads),
|
|
});
|
|
}
|
|
for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) {
|
|
this._spawnUnit(capitals[i], i, this.protoUnits[0]);
|
|
}
|
|
this._recomputeTerritory();
|
|
this._generatePopulation();
|
|
this._initPolitics();
|
|
this._generateEthnicMakeup();
|
|
this._balanceStartingEconomy();
|
|
// Every nation opens with the resource works its economy needs, laid out
|
|
// before the budgets are founded so the opening treasury already reflects
|
|
// them.
|
|
this._seedResourceBuildings();
|
|
// The terrain and where the starter industry landed give each region a
|
|
// different income per head. Unworked land is not what makes a region rich
|
|
// (food comes from the land, not the headcount), so people now move out of
|
|
// the poor regions until every region of a nation is level.
|
|
this._equalizeRegionIncomes();
|
|
// The starter industry lifts raw GDP unevenly; restore the equal starting
|
|
// production per head without disturbing the grid demand the seeding planned.
|
|
this._rebalanceProduction();
|
|
// Budgets are founded once the balanced economy is known, so every nation
|
|
// opens on 1 January 2000 with a tenth of its own starting GDP.
|
|
this._initBudgets();
|
|
},
|
|
|
|
_placeCapitals(count) {
|
|
const rng = new Random(this.seed);
|
|
return this._generator.pickCapitals(count, rng);
|
|
},
|
|
|
|
_placeCities(capitals) {
|
|
return this._generator.pickCities(capitals, this.mapConfig.citiesPerCiv, this.mapConfig.citySpacing);
|
|
},
|
|
|
|
_spawnCity(site) {
|
|
const civIndex = site.civIndex;
|
|
if (civIndex < 0 || civIndex >= this.civilisations.length) return;
|
|
const civ = this.civilisations[civIndex];
|
|
const coords = site.coords;
|
|
const isCapital = site.isCapital;
|
|
const name = this._nextCityName(civ, civIndex);
|
|
const population = 500000 +
|
|
(Math.abs((coords.x * 92837111) ^ (coords.y * 689287499)) % 3500000);
|
|
const city = {
|
|
id: this._nextCityId,
|
|
civ: civIndex,
|
|
coords,
|
|
name,
|
|
isCapital,
|
|
population,
|
|
improvements: [],
|
|
buildings: {},
|
|
statuses: [],
|
|
};
|
|
this.cities.push(city);
|
|
this._cityById.set(this._nextCityId, city);
|
|
this._nextCityId += 1;
|
|
return city;
|
|
},
|
|
|
|
_rebuildCityIndex() {
|
|
this._cityByCoords = new Map();
|
|
this._cityAdjacent = new Map();
|
|
for (const city of this.cities) {
|
|
this._cityByCoords.set(key(city.coords.x, city.coords.y), city);
|
|
const owner = city.civ;
|
|
if (!this._cityAdjacent.has(owner)) this._cityAdjacent.set(owner, new Set());
|
|
const adjacent = this._cityAdjacent.get(owner);
|
|
for (const neighbour of this._neighbours(city.coords)) {
|
|
adjacent.add(key(neighbour.x, neighbour.y));
|
|
}
|
|
}
|
|
},
|
|
|
|
// Every naval city opens the game with a port. It is the only way a nation can
|
|
// reach resources on other continents, so a coastal city is never landlocked
|
|
// in trade from the first turn.
|
|
_seedCityPorts() {
|
|
const index = this.protoBuildings.findIndex((proto) => proto.id === "port");
|
|
if (index < 0) return;
|
|
for (const city of this.cities) {
|
|
if (this.isCoastalCity(city)) city.buildings[index] = 1;
|
|
}
|
|
},
|
|
|
|
// Picks the civilisation's next city name, skipping any a city elsewhere has
|
|
// already taken, and falls back to a numbered name so the whole world never
|
|
// has two cities called the same thing.
|
|
_nextCityName(civ, index) {
|
|
const start = this._cityNamesUsed.get(index) || 0;
|
|
for (let i = start; i < civ.cityNames.length; i++) {
|
|
const candidate = civ.cityNames[i];
|
|
if (this._usedCityNames.has(candidate)) continue;
|
|
this._cityNamesUsed.set(index, i + 1);
|
|
this._usedCityNames.add(candidate);
|
|
return candidate;
|
|
}
|
|
let suffix = Math.max(start, civ.cityNames.length) + 1;
|
|
let candidate = `${civ.name} ${suffix}`;
|
|
while (this._usedCityNames.has(candidate)) {
|
|
suffix += 1;
|
|
candidate = `${civ.name} ${suffix}`;
|
|
}
|
|
this._cityNamesUsed.set(index, suffix);
|
|
this._usedCityNames.add(candidate);
|
|
return candidate;
|
|
},
|
|
|
|
// Lays the pre-generated road network. Cities are founded on a railway (they
|
|
// improve themselves), so they are free nodes while the network is drawn; the
|
|
// chosen links are the greedy spanner over every possible city-to-city route.
|
|
_generateRoads(roadKeys = null) {
|
|
const railwayName = transportImprovement("railway").name;
|
|
// A cached world hands back the (read-only) road tiles; copy them so no
|
|
// state can alter the cache.
|
|
this.roads = roadKeys
|
|
? new Set(roadKeys)
|
|
: buildRoadNetwork(this.cities, this.topology, this.tiles);
|
|
// A city always stands on a railway, so no player has to improve it.
|
|
for (const city of this.cities) {
|
|
if (!city.improvements.includes(railwayName)) city.improvements.push(railwayName);
|
|
const k = key(city.coords.x, city.coords.y);
|
|
this.roads.delete(k);
|
|
this.railways.add(k);
|
|
}
|
|
},
|
|
|
|
_spawnUnit(center, civIndex, proto, homeCityId = null) {
|
|
let index = this.protoUnits.indexOf(proto);
|
|
if (index < 0) {
|
|
this.protoUnits.push(proto);
|
|
index = this.protoUnits.length - 1;
|
|
}
|
|
const unit = {
|
|
id: this._nextUnitId,
|
|
civ: civIndex,
|
|
proto: index,
|
|
coords: center,
|
|
hp: proto.maxHp,
|
|
maxHp: proto.maxHp,
|
|
path: [],
|
|
pathIndex: 0,
|
|
progressHours: 0,
|
|
moveSpeed: null,
|
|
waypoints: [],
|
|
invader: false,
|
|
statuses: [],
|
|
// A standing bombardment target (ground batteries) or a pending bomb run
|
|
// (aircraft), plus the hour an aircraft may strike again.
|
|
strikeTarget: null,
|
|
strikeReadyHour: 0,
|
|
missionFlown: false,
|
|
// A transport's cargo and any boarding under way.
|
|
cargo: [],
|
|
embarking: null,
|
|
// A land unit's carried food, in days. Air and naval units use their own
|
|
// endurance models instead.
|
|
foodDays: RESOURCE_RULES.unitSupplyDays,
|
|
};
|
|
if (proto.air) {
|
|
unit.homeCityId = homeCityId;
|
|
unit.airHours = proto.enduranceHours;
|
|
unit.airborne = false;
|
|
unit.autoReturn = false;
|
|
}
|
|
this.units.push(unit);
|
|
this._unitById.set(this._nextUnitId, unit);
|
|
this._indexUnit(unit);
|
|
this._nextUnitId += 1;
|
|
return unit;
|
|
},
|
|
|
|
_generatePopulation() {
|
|
this.tilePopulation = new Map();
|
|
for (const k in this.tiles) {
|
|
if (this._isLand(parseKey(k))) this.tilePopulation.set(k, this._basePopulation(parseKey(k)));
|
|
}
|
|
for (const city of this.cities) {
|
|
this.tilePopulation.set(key(city.coords.x, city.coords.y), city.population);
|
|
}
|
|
},
|
|
|
|
_basePopulation(coords) {
|
|
return Math.round(ECONOMY.basePopulation * this.terrainStats.populationMultiplier(coords));
|
|
},
|
|
|
|
_initBudgets() {
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
this.budgets.set(i, this.getPlayerGdp(i) * ECONOMY.startingBudgetFraction);
|
|
}
|
|
},
|
|
|
|
// Map generation should not hand one civilisation a richer start than
|
|
// another. Terrain makes the raw territories unequal, so after the world is
|
|
// populated we scale each civ's population to the common mean and then its
|
|
// production per head to the mean of the raw figures. Total population
|
|
// and total GDP are preserved; from here on the factors are constants and
|
|
// terrain, buildings, technology and war move the numbers normally.
|
|
_balanceStartingEconomy() {
|
|
const count = this.civilisations.length;
|
|
this._populationBalance = new Map();
|
|
this._productionBalance = new Map();
|
|
if (count <= 1) return;
|
|
const bases = [];
|
|
let totalPopulation = 0;
|
|
let totalGdp = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
const base = this._basePlayerAggregates(i);
|
|
bases.push(base);
|
|
totalPopulation += base.population;
|
|
totalGdp += base.gdp;
|
|
}
|
|
if (totalPopulation <= 0) return;
|
|
const targetPopulation = totalPopulation / count;
|
|
const targetPerHead = totalGdp / totalPopulation;
|
|
// Kept so the production balance can be restored once the starter industry
|
|
// has been laid out and the raw figures have moved.
|
|
this._targetProductionPerHead = targetPerHead;
|
|
for (let i = 0; i < count; i++) {
|
|
const base = bases[i];
|
|
if (base.population <= 0) continue;
|
|
const perHead = base.gdp / base.population;
|
|
this._populationBalance.set(i, targetPopulation / base.population);
|
|
this._productionBalance.set(i, perHead > 0 ? targetPerHead / perHead : 1);
|
|
}
|
|
},
|
|
|
|
// Seeding the starter resource works changes every nation's raw GDP, but the
|
|
// production balance must still hold: nations should not open richer just
|
|
// because their land happens to host more industry. Re-derive the production
|
|
// factor from the post-seeding figures against the same target, which also
|
|
// leaves each nation's balanced GDP (and so its grid demand) exactly where
|
|
// the seeding planned it.
|
|
_rebalanceProduction() {
|
|
if (!(this._targetProductionPerHead > 0)) return;
|
|
for (let i = 0; i < this.civilisations.length; i++) {
|
|
const base = this._basePlayerAggregates(i);
|
|
if (base.population <= 0) continue;
|
|
const perHead = base.gdp / base.population;
|
|
this._productionBalance.set(i, perHead > 0 ? this._targetProductionPerHead / perHead : 1);
|
|
}
|
|
this._clearTileGdpCache();
|
|
},
|
|
|
|
_isLand(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
return !!tile && tile.terrainClass === "Land";
|
|
},
|
|
|
|
_isNextToCity(coords, owner) {
|
|
const adjacent = this._cityAdjacent.get(owner);
|
|
return adjacent ? adjacent.has(key(coords.x, coords.y)) : false;
|
|
},
|
|
};
|