Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
346 lines
13 KiB
JavaScript
346 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;
|
|
},
|
|
|
|
_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._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._gdpPerCapitaCache.clear();
|
|
},
|
|
|
|
_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;
|
|
},
|
|
};
|