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, ...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; } // 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, "gdpMultiplier"); } } 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() { const cfg = config({ citiesPerCiv: 3, citySpacing: 5 }); 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); } test_poles_are_ice() { const cfg = config({ iceLatitude: 0.5 }); const generator = generate(cfg); const half = cfg.mapSize.y / 2; for (const k in generator.tiles) { const i = k.indexOf(","); const y = Number(k.slice(i + 1)); if (Math.abs(y) / half >= cfg.iceLatitude) { this.assertEqual(generator.tiles[k].terrainType, "Ice", `tile ${k}`); } } } 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"); } } 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: 1 }))); 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: 1 }))); 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"]) { this.assertTrue(terrainTypes.has(type), `${type} appears`); } } test_biome_clusters_stay_small() { const generator = generate(); for (const biome of MAP_CONFIG.terrainBiomes) { const largest = Math.round(biome.size * 1.5); 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" ); } }