Files
Battle-for-Tismo/shared/game_state/world.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
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.
2026-09-22 14:34:14 +02:00

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