Files
Battle-for-Tismo/tests/map_generator_test.js
T

276 lines
10 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,
...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;
const core = cfg.iceLatitude + (cfg.iceRoughness || 0);
for (const k in generator.tiles) {
const i = k.indexOf(",");
const y = Number(k.slice(i + 1));
if (Math.abs(y) / half >= core) {
this.assertEqual(generator.tiles[k].terrainType, "Ice", `tile ${k}`);
}
}
}
test_ice_edge_wanders_with_noise() {
const cfg = config({ iceLatitude: 0.5, iceRoughness: 0.2 });
const generator = generate(cfg);
const coords = { x: 0, y: 6 };
this.assertTrue(generator._isPolar(coords, -1), "the edge can reach south");
this.assertFalse(generator._isPolar(coords, 1), "the edge can pull north");
}
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_tundra_fringes_the_poles() {
const cfg = config();
const generator = generate(cfg);
const half = cfg.mapSize.y / 2;
const inner = cfg.tundraLatitude - (cfg.iceRoughness || 0);
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));
this.assertGreaterOrEqual(Math.abs(y) / half, inner, `tundra at ${k} is polar`);
}
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: 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", "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"
);
}
}