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`); } } }