World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land. Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built. Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
397 lines
15 KiB
JavaScript
397 lines
15 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { MapGenerator } from "../shared/map_generator.js";
|
|
import { MapTopology, key, parseKey } from "../shared/hex.js";
|
|
import { MAP_CONFIG } from "../shared/data.js";
|
|
import { Random } from "../shared/rng.js";
|
|
|
|
function config(overrides = {}) {
|
|
return {
|
|
...MAP_CONFIG,
|
|
mapSize: { x: 32, y: 24 },
|
|
minCapitalLandmass: 1,
|
|
minCapitalDistance: 2,
|
|
// The fixture stays one continent unless a test asks for the guarantee.
|
|
minContinents: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function generate(cfg = config()) {
|
|
const topology = new MapTopology(cfg.mapSize, cfg.topology === "cylindrical");
|
|
const generator = new MapGenerator(cfg, topology);
|
|
generator.generate(cfg.terrainSeed);
|
|
return generator;
|
|
}
|
|
|
|
// Sizes of the connected bodies of one terrain class, in tiles. `matches` is
|
|
// either a terrain class name or a predicate over tiles.
|
|
function componentSizes(generator, matches) {
|
|
const test = typeof matches === "function" ? matches : (tile) => tile.terrainClass === matches;
|
|
const seen = new Set();
|
|
const sizes = [];
|
|
for (const k in generator.tiles) {
|
|
if (seen.has(k) || !test(generator.tiles[k])) continue;
|
|
const frontier = [parseKey(k)];
|
|
seen.add(k);
|
|
let size = 0;
|
|
while (frontier.length > 0) {
|
|
const coords = frontier.pop();
|
|
size += 1;
|
|
for (const neighbour of generator.topology.neighbours(coords.x, coords.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (seen.has(nk)) continue;
|
|
const tile = generator.tiles[nk];
|
|
if (!tile || !test(tile)) continue;
|
|
seen.add(nk);
|
|
frontier.push(neighbour);
|
|
}
|
|
}
|
|
sizes.push(size);
|
|
}
|
|
return sizes;
|
|
}
|
|
|
|
// Number of separate connected bodies of sea on the generated map.
|
|
function seaBodies(generator) {
|
|
return componentSizes(generator, "Sea").length;
|
|
}
|
|
|
|
// A tile -> landmass id map, so a test can tell which landmass a capital sits
|
|
// on and whether two of them share one.
|
|
function landmassIndex(generator) {
|
|
const index = new Map();
|
|
let next = 0;
|
|
for (const k in generator.tiles) {
|
|
if (index.has(k) || generator.tiles[k].terrainClass !== "Land") continue;
|
|
const frontier = [parseKey(k)];
|
|
index.set(k, next);
|
|
while (frontier.length > 0) {
|
|
const coords = frontier.pop();
|
|
for (const neighbour of generator.topology.neighbours(coords.x, coords.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (index.has(nk)) continue;
|
|
const tile = generator.tiles[nk];
|
|
if (!tile || tile.terrainClass !== "Land") continue;
|
|
index.set(nk, next);
|
|
frontier.push(neighbour);
|
|
}
|
|
}
|
|
next += 1;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
// Number of land tiles that touch a sea tile: a proxy for how rough the coast
|
|
// is, since a wavier shore has more land-water edges.
|
|
function coastLength(generator) {
|
|
let edges = 0;
|
|
for (const k in generator.tiles) {
|
|
if (generator.tiles[k].terrainClass !== "Land") continue;
|
|
const i = k.indexOf(",");
|
|
const x = Number(k.slice(0, i));
|
|
const y = Number(k.slice(i + 1));
|
|
for (const neighbour of generator.topology.neighbours(x, y)) {
|
|
const tile = generator.tiles[key(neighbour.x, neighbour.y)];
|
|
if (tile && tile.terrainClass !== "Land") edges += 1;
|
|
}
|
|
}
|
|
return edges;
|
|
}
|
|
|
|
export class MapGeneratorTest extends TestCase {
|
|
test_generate_is_deterministic_for_a_seed() {
|
|
const a = generate();
|
|
const b = generate();
|
|
this.assertEqual(a.landCells, b.landCells);
|
|
this.assertEqual(Object.keys(a.tiles).length, Object.keys(b.tiles).length);
|
|
}
|
|
|
|
test_generate_marks_every_cell_as_land_or_sea() {
|
|
const cfg = config();
|
|
const generator = generate(cfg);
|
|
this.assertNotEmpty(generator.landCells);
|
|
this.assertSize(generator.tiles, cfg.mapSize.x * cfg.mapSize.y);
|
|
for (const coords of generator.landCells) {
|
|
this.assertEqual(generator.tiles[key(coords.x, coords.y)].terrainClass, "Land");
|
|
}
|
|
}
|
|
|
|
test_generate_records_pure_tile_data() {
|
|
const generator = generate();
|
|
for (const coords of generator.landCells) {
|
|
const tile = generator.tiles[key(coords.x, coords.y)];
|
|
this.assertHas(tile, "terrainClass");
|
|
this.assertHas(tile, "movementCostMultiplier");
|
|
this.assertHas(tile, "populationMultiplier");
|
|
this.assertHas(tile, "productionMultiplier");
|
|
}
|
|
}
|
|
|
|
test_pick_capitals_returns_land_within_count() {
|
|
const generator = generate();
|
|
const capitals = generator.pickCapitals(3, new Random(1));
|
|
this.assertSize(capitals, 3);
|
|
for (const coords of capitals) {
|
|
this.assertTrue(
|
|
generator.landCells.some((c) => c.x === coords.x && c.y === coords.y),
|
|
`capital ${coords.x},${coords.y} is land`
|
|
);
|
|
}
|
|
}
|
|
|
|
test_pick_capitals_respects_distance() {
|
|
const cfg = config({ minCapitalDistance: 3 });
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(2, new Random(1));
|
|
this.assertSize(capitals, 2);
|
|
const distance = generator.topology.tileDistance(capitals[0], capitals[1]);
|
|
this.assertGreaterOrEqual(distance, cfg.minCapitalDistance, "capitals kept apart");
|
|
}
|
|
|
|
test_pick_cities_gives_every_civilisation_the_same_count() {
|
|
// No speckle: a lone islet would rightly be settled on its own, which would
|
|
// skew the shared count this test is about.
|
|
const cfg = config({ citiesPerCiv: 3, citySpacing: 5, minIslandFraction: 0.02, minLakeFraction: 0.02 });
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(3, new Random(1));
|
|
this.assertSize(capitals, 3);
|
|
const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
|
|
const counts = [0, 0, 0];
|
|
for (const site of sites) counts[site.civIndex] += 1;
|
|
this.assertEqual(counts, [3, 3, 3], "every civ got the same number of cities");
|
|
}
|
|
|
|
test_pick_cities_includes_one_site_per_capital() {
|
|
const cfg = config({ citiesPerCiv: 3, citySpacing: 2 });
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(2, new Random(1));
|
|
const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
|
|
this.assertNotEmpty(sites);
|
|
const seen = new Set();
|
|
let capitalSites = 0;
|
|
for (const site of sites) {
|
|
if (site.isCapital) capitalSites += 1;
|
|
const k = key(site.coords.x, site.coords.y);
|
|
this.assertFalse(seen.has(k), `duplicate city at ${k}`);
|
|
seen.add(k);
|
|
}
|
|
this.assertEqual(capitalSites, capitals.length);
|
|
}
|
|
|
|
// Ice only ever lies in the `iceRows` rows closest to each edge, so the
|
|
// sheets never wander more than a few rows toward the equator.
|
|
test_ice_is_only_the_outermost_rows() {
|
|
const cfg = config({ iceRows: 3 });
|
|
const generator = generate(cfg);
|
|
const height = cfg.mapSize.y;
|
|
const originY = -Math.floor(height / 2);
|
|
let ice = 0;
|
|
for (const k in generator.tiles) {
|
|
if (generator.tiles[k].terrainType !== "Ice") continue;
|
|
ice += 1;
|
|
const y = Number(k.slice(k.indexOf(",") + 1));
|
|
const row = y - originY;
|
|
const edge = Math.min(row, height - 1 - row);
|
|
this.assertLess(edge, generator.iceRowCount(), `ice at ${k} is beyond the outermost rows`);
|
|
}
|
|
this.assertGreater(ice, 0, "the poles are frozen");
|
|
}
|
|
|
|
test_equator_is_not_ice() {
|
|
const generator = generate();
|
|
for (const k in generator.tiles) {
|
|
const i = k.indexOf(",");
|
|
const y = Number(k.slice(i + 1));
|
|
if (Math.abs(y) < 2) this.assertNotEqual(generator.tiles[k].terrainType, "Ice");
|
|
}
|
|
}
|
|
|
|
// Tundra only fringes the ice: it lies within `tundraRows` rows of the ice
|
|
// sheets' furthest row and never reaches further equatorward.
|
|
test_tundra_stays_within_three_rows_of_the_ice() {
|
|
const cfg = config({ iceRows: 3, tundraRows: 3 });
|
|
const generator = generate(cfg);
|
|
const height = cfg.mapSize.y;
|
|
const originY = -Math.floor(height / 2);
|
|
const ice = generator.iceRowCount();
|
|
const band = ice + generator.tundraRowCount();
|
|
let tundra = 0;
|
|
for (const k in generator.tiles) {
|
|
if (generator.tiles[k].terrainType !== "Tundra") continue;
|
|
tundra += 1;
|
|
const y = Number(k.slice(k.indexOf(",") + 1));
|
|
const row = y - originY;
|
|
const edge = Math.min(row, height - 1 - row);
|
|
this.assertGreaterOrEqual(edge, ice, `tundra at ${k} is not beyond the ice`);
|
|
this.assertLess(edge, band, `tundra at ${k} is too far from the ice`);
|
|
}
|
|
this.assertGreater(tundra, 0, "there is tundra near the poles");
|
|
}
|
|
|
|
test_falloff_lowers_the_edges() {
|
|
const cfg = config({ falloffEnabled: true, falloffStart: 0, falloffStrength: 1 });
|
|
const generator = generate(cfg);
|
|
this.assertApprox(generator._applyFalloff({ x: 0, y: 0 }, 1.0, false), 1.0, 1e-9);
|
|
this.assertApprox(generator._applyFalloff({ x: 16, y: 12 }, 1.0, false), 0.0, 1e-9);
|
|
}
|
|
|
|
test_raw_terrain_can_have_separate_seas() {
|
|
const separate = seaBodies(generate(config({ connectOceans: false, terrainSeed: 4 })));
|
|
this.assertGreater(separate, 1, "this seed really needs joining up");
|
|
}
|
|
|
|
test_every_sea_is_joined_into_one_ocean() {
|
|
const bodies = seaBodies(generate(config({ connectOceans: true, terrainSeed: 4 })));
|
|
this.assertEqual(bodies, 1, "one connected ocean");
|
|
}
|
|
|
|
test_ocean_connection_is_deterministic() {
|
|
const a = generate(config({ connectOceans: true }));
|
|
const b = generate(config({ connectOceans: true }));
|
|
this.assertEqual(a.tiles, b.tiles);
|
|
this.assertEqual(a.landCells, b.landCells);
|
|
}
|
|
|
|
test_small_islands_and_lakes_are_smoothed_away() {
|
|
const cfg = config({ connectOceans: false, minIslandFraction: 0.03, minLakeFraction: 0.03 });
|
|
const generator = generate(cfg);
|
|
const min = Math.round(cfg.mapSize.x * cfg.mapSize.y * 0.03);
|
|
const land = componentSizes(generator, "Land");
|
|
const sea = componentSizes(generator, "Sea");
|
|
this.assertNotEmpty(land, "land remains");
|
|
this.assertNotEmpty(sea, "sea remains");
|
|
for (const size of land) this.assertGreaterOrEqual(size, min, `landmass of ${size} smoothed away`);
|
|
for (const size of sea) this.assertGreaterOrEqual(size, min, `lake of ${size} smoothed away`);
|
|
}
|
|
|
|
test_every_biome_is_placed_on_the_land() {
|
|
const generator = generate();
|
|
const terrainTypes = new Set();
|
|
for (const coords of generator.landCells) {
|
|
terrainTypes.add(generator.tiles[key(coords.x, coords.y)].terrainType);
|
|
}
|
|
for (const type of ["Land", "Forest", "Hills", "Mountain", "Desert", "Tundra"]) {
|
|
this.assertTrue(terrainTypes.has(type), `${type} appears`);
|
|
}
|
|
}
|
|
|
|
test_biome_clusters_stay_small() {
|
|
const generator = generate();
|
|
for (const biome of MAP_CONFIG.terrainBiomes) {
|
|
// Same-type patches may grow into each other, so a connected body is
|
|
// bounded by the total area the biome was asked to cover, not by one
|
|
// patch. A biome still cannot run away and swallow the whole map.
|
|
const target = Math.round((biome.coverage || 0) * generator.landCells.length);
|
|
const largest = Math.max(Math.round(biome.size * 1.5), target);
|
|
const sizes = componentSizes(
|
|
generator,
|
|
(tile) => tile.terrainType === biome.terrainType
|
|
);
|
|
for (const size of sizes) {
|
|
this.assertTrue(
|
|
size <= largest,
|
|
`${biome.terrainType} cluster of ${size} exceeds ${largest}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
test_a_rough_coast_has_more_shore_than_a_smooth_one() {
|
|
const smooth = generate(config({ coastRoughness: 0 }));
|
|
const rough = generate(config({ coastRoughness: 0.4 }));
|
|
this.assertGreater(
|
|
coastLength(rough),
|
|
coastLength(smooth),
|
|
"the roughened coast has a longer shore"
|
|
);
|
|
}
|
|
|
|
// The water search has to leave the land at the requested share of the
|
|
// ice-free tiles, on the real map, for every seed.
|
|
test_land_covers_sixty_percent_of_the_ice_free_tiles() {
|
|
const seeds = [0, 1, 2, 3, 7, 42, 12345, 999, 2024, 5];
|
|
for (const seed of seeds) {
|
|
// The real map size, so the test covers the configuration the game uses.
|
|
const cfg = config({ terrainSeed: seed, mapSize: { x: 100, y: 100 } });
|
|
const generator = generate(cfg);
|
|
let land = 0;
|
|
let iceFree = 0;
|
|
for (const k in generator.tiles) {
|
|
const tile = generator.tiles[k];
|
|
if (tile.terrainClass === "Ice") continue;
|
|
iceFree += 1;
|
|
if (tile.terrainClass === "Land") land += 1;
|
|
}
|
|
this.assertApprox(land / iceFree, cfg.landFraction, 0.02, `seed ${seed} land fraction`);
|
|
}
|
|
}
|
|
|
|
// No landmass may be left without a city, so every island is settled.
|
|
test_every_landmass_is_settled() {
|
|
const cfg = config({ terrainSeed: 12345, mapSize: { x: 100, y: 100 }, nationCount: 6 });
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(6, new Random(1));
|
|
const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
|
|
const index = landmassIndex(generator);
|
|
const settled = new Set();
|
|
for (const site of sites) {
|
|
const id = index.get(key(site.coords.x, site.coords.y));
|
|
if (id !== undefined) settled.add(id);
|
|
}
|
|
const landmasses = new Set(index.values());
|
|
for (const id of landmasses) {
|
|
this.assertTrue(settled.has(id), `landmass ${id} has a city`);
|
|
}
|
|
}
|
|
|
|
// Every civilisation that holds a landmass must hold a coastal city there, so
|
|
// every island it owns has a port to the sea.
|
|
test_every_civilisation_gets_a_coastal_city_per_landmass() {
|
|
const cfg = config({ terrainSeed: 12345, mapSize: { x: 100, y: 100 }, nationCount: 6 });
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(6, new Random(1));
|
|
const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
|
|
const index = landmassIndex(generator);
|
|
const held = new Map();
|
|
for (const site of sites) {
|
|
const id = index.get(key(site.coords.x, site.coords.y));
|
|
const group = `${id}:${site.civIndex}`;
|
|
const coastal = generator._isCoastal(site.coords);
|
|
if (coastal) held.set(group, true);
|
|
else if (!held.has(group)) held.set(group, false);
|
|
}
|
|
for (const [group, coastal] of held) {
|
|
this.assertTrue(coastal, `${group} has a coastal city`);
|
|
}
|
|
}
|
|
|
|
// Every small island is a starting island: one nation per island, placed on
|
|
// the coast so it can put a port to sea, before the rest go on the
|
|
// continents.
|
|
test_every_small_island_spawns_a_nation() {
|
|
const nations = 6;
|
|
const cfg = { ...MAP_CONFIG, nationCount: nations, terrainSeed: 12345 };
|
|
const generator = generate(cfg);
|
|
const capitals = generator.pickCapitals(nations, new Random(1));
|
|
this.assertSize(capitals, nations, "every nation got a capital");
|
|
const islands = generator.smallIslandCenters.filter((center) => generator._isLand(center));
|
|
this.assertSize(islands, 5, "the map seeds five islands");
|
|
this.assertLess(islands.length, nations, "there are more nations than islands");
|
|
const index = landmassIndex(generator);
|
|
const perLandmass = new Map();
|
|
for (const capital of capitals) {
|
|
const id = index.get(key(capital.x, capital.y));
|
|
perLandmass.set(id, (perLandmass.get(id) || 0) + 1);
|
|
}
|
|
for (const center of islands) {
|
|
const id = index.get(key(center.x, center.y));
|
|
this.assertEqual(perLandmass.get(id) || 0, 1, `island ${center.x},${center.y} has one nation`);
|
|
}
|
|
// The island nations came first, so the capitals for `0..islands-1` are the
|
|
// island sites and each sits on the shore.
|
|
for (let civ = 0; civ < islands.length; civ++) {
|
|
this.assertTrue(generator._isCoastal(capitals[civ]), `island capital ${civ} is coastal`);
|
|
}
|
|
}
|
|
}
|