- aircraft attack orders fly a sortie to the target, bomb once, return to a friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing is bounded by fuel rather than the 10-tile combat radius - artillery and warships open a sustained hourly bombardment on a standing target, drawn as a red dashed line and ring while either endpoint is selected - a game launched from localhost can enable a testing mode whose train and build rows offer free, instant orders; the server only honours them for a loopback admin in a testing game - an open city panel now unlocks aircraft as soon as an airport finishes
290 lines
10 KiB
JavaScript
290 lines
10 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 { ROADS } from "../data/roads.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._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._balanceStartingEconomy();
|
|
// 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));
|
|
}
|
|
}
|
|
},
|
|
|
|
// 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 with a road, 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) {
|
|
for (const city of this.cities) {
|
|
if (!city.improvements.includes(ROADS.improvementName)) {
|
|
city.improvements.push(ROADS.improvementName);
|
|
}
|
|
}
|
|
// 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);
|
|
},
|
|
|
|
_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,
|
|
};
|
|
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
|
|
// GDP per capita to the mean of the raw per-capita 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._perCapitaBalance = 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 targetPerCapita = totalGdp / totalPopulation;
|
|
for (let i = 0; i < count; i++) {
|
|
const base = bases[i];
|
|
if (base.population <= 0) continue;
|
|
const perCapita = base.gdp / base.population;
|
|
this._populationBalance.set(i, targetPopulation / base.population);
|
|
this._perCapitaBalance.set(i, perCapita > 0 ? targetPerCapita / perCapita : 1);
|
|
}
|
|
},
|
|
|
|
_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;
|
|
},
|
|
};
|