276 lines
10 KiB
JavaScript
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"
|
|
);
|
|
}
|
|
}
|