// 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; }, };