- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep. - Resource-producing tile works built outside cities; every nation opens with the works its economy needs. - Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
354 lines
14 KiB
JavaScript
354 lines
14 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() {
|
|
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: 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 seeded domes plus the water search should give exactly the requested
|
|
// number of continents on every seed, and none of them may dwarf the others.
|
|
test_seeded_domes_leave_exactly_the_requested_continents() {
|
|
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({ minContinents: 3, terrainSeed: seed, mapSize: { x: 100, y: 100 } });
|
|
const generator = generate(cfg);
|
|
// A continent is a body of at least the configured fraction of the map;
|
|
// the small islands the detail noise throws off do not count.
|
|
const min = Math.round(cfg.mapSize.x * cfg.mapSize.y * cfg.minContinentFraction);
|
|
const continents = componentSizes(generator, "Land").filter((size) => size >= min);
|
|
this.assertEqual(continents.length, 3, `seed ${seed} has three continents`);
|
|
const total = continents.reduce((sum, size) => sum + size, 0);
|
|
this.assertLess(
|
|
Math.max(...continents),
|
|
total * 0.6,
|
|
`seed ${seed} has no single dominant continent`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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`);
|
|
}
|
|
}
|
|
}
|