Grew continents from seeded domes and gave every small island a nation

This commit is contained in:
2026-09-21 20:24:30 +02:00
parent 75bddddb7f
commit bc18598bcf
8 changed files with 557 additions and 65 deletions
+56 -10
View File
@@ -5,8 +5,56 @@ export const MAP_CONFIG = {
mapSize: { x: 100, y: 100 },
topology: "cylindrical",
terrainSeed: 0,
seaLevel: -0.05,
falloffEnabled: true,
// Continents grow from `minContinents` smooth domes rather than a plain
// continent noise field. The domes are spread by farthest-point sampling and
// the sea level is then found by lowering the water in `seaLevelStep` steps
// until exactly that many landmasses remain; `seaLevel` is only the fallback
// when the search finds no exact match.
seaLevel: 0,
// Fraction of the shorter map side used as each dome's radius. There is no
// falloff: the domes are simply islands in one ocean, and where a dome
// reaches a pole it parts the ocean there.
domeRadius: 0.26,
// How far the detail noise may bend the dome edge. The field is rescaled to
// [-1, 1] per map so the coast stays rough whatever the map size.
detailAmplitude: 0.25,
// Domes are kept this fraction of a half-map away from the poles, so their
// peaks are never buried under the polar ice.
domePolarMargin: 0.25,
// The detail field that roughens the coastline. `cycles` is how many base
// waves span the map width, so the coasts stay ragged on a small map as well
// as on the default one; the frequency is derived per map.
detailNoise: {
cycles: 1,
octaves: 8,
lacunarity: 2.0,
gain: 0.5,
},
seaLevelStep: 0.05,
// A body of land has to reach this fraction of the map to count as a
// continent while the sea level is being chosen, so speckle cannot stand in
// for one. The water is never lowered past `maxLandFraction` either, so a
// single dome cannot swallow the whole map before the search settles.
minContinentFraction: 0.01,
maxLandFraction: 0.6,
// A few small islands are stamped into open water after the continents have
// settled. They never join a continent or sway the water search, and each is
// kept under `minCapitalLandmass`. The count is scaled by map area (so the
// small test fixtures get none) and capped at the number of nations, because
// every island is a starting island: `pickCapitals` puts one nation on each
// before the rest go on the continents.
smallIslandCount: 5,
smallIslandSize: 30,
// A field that chews the small-island rims so they read as ragged coast
// rather than as dice. It is coarse enough to grow lobes a couple of tiles
// across without splitting the island.
smallIslandNoise: {
frequency: 0.14,
octaves: 2,
lacunarity: 2.0,
gain: 0.5,
},
falloffEnabled: false,
falloffStart: 0.55,
falloffStrength: 0.6,
iceLatitude: 0.9,
@@ -22,14 +70,8 @@ export const MAP_CONFIG = {
gain: 0.5,
},
iceRoughness: 0.06,
noise: {
frequency: 0.02,
octaves: 1,
lacunarity: 2.0,
gain: 0.5,
},
// The finer field that roughens the coastline, and how strongly it bends the
// shore away from the smooth continent outline.
// A finer field that roughens the coastline on top of the detail noise, and
// how strongly it bends the shore away from the smooth dome outline.
coastNoise: {
frequency: 0.09,
octaves: 2,
@@ -55,6 +97,10 @@ export const MAP_CONFIG = {
// thresholds scale with the map, so tiny test maps are left alone.
minIslandFraction: 0.0015,
minLakeFraction: 0.0015,
// How many continents to seed and to keep once the water has settled: the
// generator places one dome per continent and searches for the sea level that
// leaves exactly this many landmasses.
minContinents: 3,
connectOceans: true,
citiesPerCiv: 10,
// Minimum distance between any two cities during generation (never below 5).
+5
View File
@@ -253,6 +253,11 @@ export class GameState {
configure(civs, worldSeed = 0) {
this.civilisations = civs.slice();
// The map seeds one small island per nation (up to the map's island
// budget), and those islands are the first capitals, so generation has to
// know how many nations are playing. The figure travels in the snapshot so
// the browser rebuilds the very same islands.
this.mapConfig = { ...this.mapConfig, nationCount: this.civilisations.length };
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
// Combat rolls (a city losing a building level to a bombardment) draw from
// their own generator, seeded from the world so a game is reproducible.
+370 -22
View File
@@ -34,9 +34,11 @@ export class MapGenerator {
}
}
// Three passes: a smooth continent field decides land and sea, a finer field
// roughens the coastline, and clusters of terrain are then scattered over the
// land independently of the continent shape.
// Continents are seeded domes: a handful of smooth peaks spread over the map,
// bent by detail noise into a ragged coast. The water level is not fixed; it
// is lowered in steps until exactly `minContinents` landmasses remain, which
// is what decides how much of the domes is above water. Terrain clusters are
// then scattered over the land independently of the continent shape.
generate(seed) {
const noise = new SimplexNoise(seed);
const halfX = Math.floor(this.config.mapSize.x / 2);
@@ -46,31 +48,326 @@ export class MapGenerator {
this.seed = seed;
this.landCells = [];
this.tiles = {};
const detail = this._detailField(noise, wrapped, radius);
const centers = this._domeCenters(seed);
const domeRadius = this._domeRadius();
const elevations = new Map();
const ice = new Set();
const iceNoise = new Map();
let minElevation = Infinity;
let maxElevation = -Infinity;
for (let row = 0; row < this.config.mapSize.y; row++) {
for (let column = 0; column < this.config.mapSize.x; column++) {
const coords = { x: column - halfX, y: row - halfY };
let value = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.noise);
value = this._applyFalloff(coords, value, wrapped);
let elevation = value;
const k = key(coords.x, coords.y);
let value = this._domeValue(coords, centers, domeRadius);
if (detail) value += (this.config.detailAmplitude || 0) * detail.get(k);
if (this.config.coastNoise) {
const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise);
elevation += (this.config.coastRoughness || 0) * coast;
value += (this.config.coastRoughness || 0) * coast;
}
let iceNoise = 0;
if (this.config.falloffEnabled) value = this._applyFalloff(coords, value, wrapped);
let polarNoise = 0;
if (this.config.iceNoise) {
iceNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise);
polarNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise);
}
const tile = this._chooseTile(coords, elevation, iceNoise);
this.tiles[key(coords.x, coords.y)] = tile;
if (this._isPolar(coords, polarNoise)) ice.add(k);
iceNoise.set(k, polarNoise);
elevations.set(k, value);
if (value < minElevation) minElevation = value;
if (value > maxElevation) maxElevation = value;
}
}
this.seaLevel = this._chooseSeaLevel(elevations, ice, minElevation, maxElevation);
for (let row = 0; row < this.config.mapSize.y; row++) {
for (let column = 0; column < this.config.mapSize.x; column++) {
const coords = { x: column - halfX, y: row - halfY };
const k = key(coords.x, coords.y);
const tile = this._chooseTile(coords, elevations.get(k), iceNoise.get(k));
this.tiles[k] = tile;
if (tile.terrainClass === "Land") this.landCells.push(coords);
}
}
this._removeSmallFeatures();
if (this.config.connectOceans !== false) this._connectOceans();
this._placeSmallIslands(seed, iceNoise);
// Sweep up any speck left behind, including the slivers the ocean channels
// may have cut and any island the noise thinned below the small-island size.
this._removeSmallFeatures();
this._placeTerrainClusters();
this._computeLandmasses();
}
// How many small islands this map gets, scaled by area so the small test
// fixtures, which are a fraction of the reference map, get none. Never more
// than there are nations, so every island ends up with a nation on it.
_smallIslandCount() {
const base = this.config.smallIslandCount || 0;
const area = this.config.mapSize.x * this.config.mapSize.y;
let count = Math.round((base * area) / (100 * 100));
const nations = this.config.nationCount;
if (Number.isFinite(nations)) count = Math.min(count, Math.max(0, nations));
return count;
}
// Stamps a few noise-edged blobs of land into open water. They are placed
// after the continents have settled, so they can never join one or affect the
// water search, and every candidate sits far enough from land that the whole
// blob stays clear of the coast. Each blob is sized well below
// `minCapitalLandmass`, so it reads as an island and hosts no capital.
_placeSmallIslands(seed, iceNoise) {
const count = this._smallIslandCount();
if (count <= 0) return;
const target = Math.max(4, Math.round(this.config.smallIslandSize || 30));
const radius = Math.max(2, Math.round(Math.sqrt(target / Math.PI)));
// The rim may reach a couple of tiles past `radius`, so candidates keep that
// much water between the blob and the nearest coast.
const distance = this._seaDistanceToLand(radius + 2);
const candidates = [];
for (const k in this.tiles) {
if (this.tiles[k].terrainClass !== "Sea") continue;
const d = distance.get(k);
if (d !== undefined && d <= radius + 2) continue;
candidates.push(parseKey(k));
}
if (candidates.length === 0) return;
const rng = new Random((seed ^ 0x27d4eb2f) >>> 0);
const centers = [candidates[rng.range(0, candidates.length - 1)]];
while (centers.length < count) {
let best = null;
let bestDistance = -1;
for (const coords of candidates) {
let nearest = Infinity;
for (const other of centers) {
const d = this.topology.tileDistance(coords, other);
if (d < nearest) nearest = d;
}
if (nearest > bestDistance) {
bestDistance = nearest;
best = coords;
}
}
// Stop rather than crowd islands together when the water is small.
if (!best || bestDistance <= radius * 2) break;
centers.push(best);
}
const plains = this._palette.plains;
const tundra = this._palette.tundra;
// The rim is cut by a fine noise field rather than by the smooth detail
// field: the latter barely changes across a few tiles, which left the
// islands as dice. A noise that varies within the footprint gives ragged,
// lobed coasts.
const edgeConfig = this.config.smallIslandNoise;
const edgeNoise = new SimplexNoise((seed ^ 0x51ed270b) >>> 0);
const wrapped = this.topology.isCylindrical();
const halfX = Math.floor(this.config.mapSize.x / 2);
const cylinder = this.config.mapSize.x / TAU;
for (const center of centers) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const coords = { x: center.x + dx, y: center.y + dy };
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Sea") continue;
const d = this.topology.tileDistance(center, coords);
const noise = edgeConfig
? this._sampleNoise(edgeNoise, coords.x + halfX, coords, wrapped, cylinder, edgeConfig)
: 0;
const irregularity = (noise + 1) / 2;
// The rim wanders about a tile either way of `radius`, which is
// enough to push it across the next hex ring and break the dice while
// still keeping every island under the capital landmass size.
if (d > radius + (irregularity - 0.5) * 1.8) continue;
const polar = iceNoise ? iceNoise.get(k) || 0 : 0;
this.tiles[k] = tundra && this._isTundra(coords, polar) ? tundra : plains;
}
}
}
this.smallIslandCenters = centers;
this._rebuildLandCells();
}
// Distance from every sea tile to the nearest non-sea tile (land or ice), up
// to `maxDistance`; sea tiles beyond that are simply absent from the map.
_seaDistanceToLand(maxDistance) {
const distance = new Map();
let frontier = [];
for (const k in this.tiles) {
if (this.tiles[k].terrainClass !== "Sea") {
distance.set(k, 0);
frontier.push(parseKey(k));
}
}
let depth = 0;
while (frontier.length > 0 && depth < maxDistance) {
const next = [];
for (const coords of frontier) {
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (distance.has(nk) || !this.tiles[nk]) continue;
if (this.tiles[nk].terrainClass !== "Sea") continue;
distance.set(nk, depth + 1);
next.push(neighbour);
}
}
frontier = next;
depth += 1;
}
return distance;
}
// The number of continents the map is built around, and so how many domes to
// scatter. Always at least one, so a map always has land.
_continentCount() {
return Math.max(1, Math.round(this.config.minContinents || 1));
}
// Radius in tiles: the configured fraction of the shorter map side, so the
// domes keep their proportions when the tests use a small map.
_domeRadius() {
const shorter = Math.min(this.config.mapSize.x, this.config.mapSize.y);
return Math.max(2, (this.config.domeRadius || 0.26) * shorter);
}
// Picks `_continentCount()` dome centres by farthest-point sampling, so they
// end up spread over the map instead of clustered. Working from the wrapped
// hex distance keeps them spread across the cylindrical seam too. Centres are
// held away from the poles so a peak is never hidden under the ice sheets.
_domeCenters(seed) {
const count = this._continentCount();
const rng = new Random((seed ^ 0x9e3779b9) >>> 0);
const halfX = Math.floor(this.config.mapSize.x / 2);
const halfY = Math.floor(this.config.mapSize.y / 2);
const margin = Math.max(1, Math.round((this.config.domePolarMargin || 0) * halfY));
const candidates = [];
for (let y = -halfY + margin; y < halfY - margin; y++) {
for (let x = -halfX; x < halfX; x++) candidates.push({ x, y });
}
if (candidates.length === 0) return [];
const centers = [candidates[rng.range(0, candidates.length - 1)]];
while (centers.length < count) {
let best = null;
let bestDistance = -1;
for (const coords of candidates) {
let nearest = Infinity;
for (const other of centers) {
const d = this.topology.tileDistance(coords, other);
if (d < nearest) nearest = d;
}
if (nearest > bestDistance) {
bestDistance = nearest;
best = coords;
}
}
centers.push(best);
}
return centers;
}
// The height of the tallest dome at `coords`: a smooth exponential bump under
// each centre, with the peaks all reaching 1 so no continent dominates.
_domeValue(coords, centers, domeRadius) {
let value = 0;
for (const center of centers) {
const d = this.topology.tileDistance(coords, center);
const bump = Math.exp(-(d * d) / (domeRadius * domeRadius));
if (bump > value) value = bump;
}
return value;
}
// The detail field that bends the dome edge into a ragged coast. It is
// rescaled to [-1, 1] so the wanted roughness holds on any map size.
_detailField(noise, wrapped, radius) {
const base = this.config.detailNoise;
if (!base) return null;
const width = Math.max(1, this.config.mapSize.x);
const config = { ...base, frequency: (base.cycles || 1) / width };
const halfX = Math.floor(this.config.mapSize.x / 2);
const halfY = Math.floor(this.config.mapSize.y / 2);
const raw = new Map();
let min = Infinity;
let max = -Infinity;
for (let row = 0; row < this.config.mapSize.y; row++) {
for (let column = 0; column < this.config.mapSize.x; column++) {
const coords = { x: column - halfX, y: row - halfY };
const value = this._sampleNoise(noise, column, coords, wrapped, radius, config);
raw.set(key(coords.x, coords.y), value);
if (value < min) min = value;
if (value > max) max = value;
}
}
const span = max - min;
const field = new Map();
for (const [k, value] of raw) {
field.set(k, span > 1e-6 ? ((value - min) / span) * 2 - 1 : 0);
}
return field;
}
// Lowers the water from the highest peak in `seaLevelStep` steps and keeps
// the lowest level that still leaves exactly the wanted number of continents.
// A continent is a connected body of land at least `minContinentFraction` of
// the map, so lone speckle cannot count. Returns the configured `seaLevel`
// only when no level gives an exact match.
_chooseSeaLevel(elevations, ice, minElevation, maxElevation) {
const target = this._continentCount();
const step = Math.max(1e-4, this.config.seaLevelStep || 0.05);
const area = this.config.mapSize.x * this.config.mapSize.y;
const threshold = Math.max(1, Math.round(area * (this.config.minContinentFraction || 0)));
const maxLand = area * (this.config.maxLandFraction || 1);
let chosen = this.config.seaLevel || 0;
let found = false;
let fallback = null;
for (let level = maxElevation; level >= minElevation - 1e-9; level -= step) {
const { continents: count, land } = this._countContinents(elevations, ice, level, threshold);
// Letting the water fall further would flood the map with land.
if (land > maxLand) break;
if (count === target) {
chosen = level;
found = true;
continue;
}
// Domes have started merging, so dropping further only loses land.
if (found && count < target) break;
if (!found) {
const difference = Math.abs(count - target);
if (!fallback || difference < fallback.difference) fallback = { level, difference };
}
}
return found ? chosen : fallback ? fallback.level : chosen;
}
// How many connected bodies of land at least `threshold` tiles sit above
// `level`, plus the total land area at that level. Polar ice counts as solid
// ground here: it is not land, but it divides the map just the same.
_countContinents(elevations, ice, level, threshold) {
const seen = new Set();
let continents = 0;
let land = 0;
for (const k of elevations.keys()) {
if (seen.has(k) || ice.has(k) || elevations.get(k) < level) continue;
seen.add(k);
const frontier = [parseKey(k)];
let size = 0;
while (frontier.length > 0) {
const coords = frontier.pop();
size += 1;
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk) || ice.has(nk)) continue;
const value = elevations.get(nk);
if (value === undefined || value < level) continue;
seen.add(nk);
frontier.push(neighbour);
}
}
land += size;
if (size >= threshold) continents += 1;
}
return { continents, land };
}
_sampleNoise(noise, column, coords, wrapped, radius, config) {
if (!wrapped) {
return noise.fbm(coords.x, coords.y, undefined, config);
@@ -102,7 +399,7 @@ export class MapGenerator {
_chooseTile(coords, elevation, iceNoise) {
const palette = this._palette;
if (palette.ice && this._isPolar(coords, iceNoise)) return palette.ice;
if (elevation < this.config.seaLevel) return palette.sea;
if (elevation < this.seaLevel) return palette.sea;
// Tundra fringes the ice sheets; land starts as plains otherwise, and the
// terrain clusters are painted over it later.
if (palette.tundra && this._isTundra(coords, iceNoise)) return palette.tundra;
@@ -198,11 +495,13 @@ export class MapGenerator {
pickCapitals(count, rng) {
if (count <= 0 || this.landCells.length === 0) return [];
// A nation on every small island comes first, so no island is left empty.
const chosen = this._islandCapitalSites(count);
if (chosen.length >= count) return chosen;
let candidates = this._capitalCandidates(false);
if (candidates.length === 0) candidates = this._capitalCandidates(true);
if (candidates.length === 0) candidates = this.landCells.slice();
const shuffled = rng.shuffle(candidates.slice());
const chosen = [];
for (const coords of shuffled) {
if (chosen.length >= count) break;
if (this._isFarEnough(coords, chosen, this.config.minCapitalDistance)) chosen.push(coords);
@@ -216,6 +515,50 @@ export class MapGenerator {
return chosen;
}
// One capital site per small island, up to `count`: the island's tile nearest
// the centre that touches the sea, so an island nation can always build a
// port. Islands the feature cleanup removed are skipped.
_islandCapitalSites(count) {
const centers = this.smallIslandCenters || [];
const chosen = [];
for (const center of centers) {
if (chosen.length >= count) break;
const site = this._coastalLandNear(center);
if (site) chosen.push(site);
}
return chosen;
}
// The land tile nearest `center` (across connected land) that touches a sea
// tile, or null when `center` is not land.
_coastalLandNear(center) {
if (!this._isLand(center)) return null;
const seen = new Set([key(center.x, center.y)]);
let frontier = [center];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
if (this._isCoastal(coords)) return { x: coords.x, y: coords.y };
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk) || !this._isLand(neighbour)) continue;
seen.add(nk);
next.push(neighbour);
}
}
frontier = next;
}
return null;
}
_isCoastal(coords) {
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
}
pickCities(capitals, citiesPerCiv, minSpacing) {
const capitalsOnly = () =>
capitals.map((coords, civIndex) => ({ coords, civIndex, isCapital: true }));
@@ -226,9 +569,9 @@ export class MapGenerator {
// with a compact block of cities instead of scattered exclaves.
const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE);
// Work one landmass at a time: each coast is given as many neutral sites as
// its capitals need, so the cluster pass can hand every civ the same count.
// its capitals need, so the cluster pass can hand every civ its share.
const groups = this._capitalGroups(capitals);
let perCiv = citiesPerCiv;
const cities = [];
for (const group of groups) {
group.layout = this._neutralCitySites(
capitals,
@@ -237,13 +580,18 @@ export class MapGenerator {
group.civs.length * citiesPerCiv,
minDistance
);
perCiv = Math.min(perCiv, Math.floor(group.layout.length / group.civs.length));
}
if (perCiv <= 1) return capitalsOnly();
// The assignment pass emits the capitals too, so it is the only source of
// cities here.
const cities = [];
for (const group of groups) {
// Each landmass keeps as many cities per civ as it has room for: a tiny
// island nation starts with little more than its capital, while the
// continent nations get their full spread. Doing this per landmass keeps
// a cramped island from starving the continents, which is why there is no
// longer one shared count.
const perCiv = Math.min(citiesPerCiv, Math.floor(group.layout.length / group.civs.length));
if (perCiv <= 1) {
for (const civ of group.civs) {
cities.push({ coords: capitals[civ], civIndex: civ, isCapital: true });
}
continue;
}
this._assignCitySites(capitals, group, perCiv, cities);
}
return cities;
+32 -29
View File
@@ -233,26 +233,27 @@ export class AirTest extends TestCase {
this.assertGreater(checked, 0);
}
test_jet_fighter_can_rebase_to_a_city_ten_tiles_away() {
test_jet_fighter_can_rebase_to_another_city() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
this.assertEqual(
this.assertGreater(
state.topology.tileDistance(home.coords, away.coords),
10,
"the fixture's two cities sit ten tiles apart"
1,
"the fixture's two cities are apart"
);
const jet = protoUnit(state, "jet_fighter");
this.assertGreaterOrEqual(jet.missionRange, 10, "the jet reaches at least ten tiles");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
unit.airHours = jet.enduranceHours;
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"the ten-tile rebase is accepted"
"the rebase to a friendly city is accepted"
);
state.advanceHour();
state.advanceHour();
for (let hour = 0; hour < 60 && unit.coords.x !== away.coords.x; hour++) {
state.advanceHour();
}
this.assertEqual(unit.coords.x, away.coords.x, "it reached the far city");
this.assertEqual(unit.coords.y, away.coords.y);
this.assertFalse(unit.airborne, "it landed and refuelled");
@@ -267,13 +268,12 @@ export class AirTest extends TestCase {
const [home, away] = cities;
const bomber = protoUnit(state, "bomber");
const unit = state._spawnUnit(home.coords, 0, bomber, home.id);
// Pin the fuel so the test does not depend on the catalogue's endurance:
// ten tiles out and ten back is four hours, beyond the two hours in the
// tanks, so it cannot strike that far even though it can fly it.
unit.airHours = 2;
// Pin the fuel to exactly the one-way trip: the city can be ferried to but
// not struck and left, because the round trip needs twice as long.
unit.airHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords));
this.assertFalse(
state._strikeReady(unit, away.coords),
"the ten-tile city is beyond the bomber's strike reach"
"the far city is beyond the bomber's strike reach"
);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
@@ -374,21 +374,17 @@ export class AirTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const bomber = protoUnit(state, "bomber");
const unit = state._spawnUnit(own.coords, 0, bomber, own.id);
const enemyCity = state.cities
.filter((c) => c.civ === 1)
.sort(
(a, b) =>
state.topology.tileDistance(own.coords, a.coords) -
state.topology.tileDistance(own.coords, b.coords)
)[0];
this.assertNotNull(enemyCity, "the fixture has an enemy city");
// The scenario garrisons every capital, so the enemy capital is the city
// that is certain to hold a defender.
const enemyCity = state.cities.find((c) => c.civ === 1 && c.isCapital);
this.assertNotNull(enemyCity, "the fixture has a garrisoned enemy capital");
// Give the plane the reach so the city tile itself is the only obstacle.
unit.airHours = 100;
const savedRange = bomber.missionRange;
bomber.missionRange = 100;
try {
// The scenario already garrisons every city, so the city tile holds a
// military unit that the strike must be allowed to bomb.
// The capital is garrisoned, so the city tile holds a military unit that
// the strike must be allowed to bomb.
const defender = state.units.find(
(u) =>
u.civ === 1 &&
@@ -400,8 +396,9 @@ export class AirTest extends TestCase {
state.requestAirStrike([unit.id], enemyCity.coords),
"a garrisoned city can be struck"
);
state.advanceHour();
state.advanceHour();
for (let hour = 0; hour < 200 && defender.hp === defender.maxHp; hour++) {
state.advanceHour();
}
this.assertLess(defender.hp, defender.maxHp, "the bomb hit the garrison");
this.assertEqual(enemyCity.civ, 1, "the bomber did not capture the city");
} finally {
@@ -546,10 +543,16 @@ export class CoastalBombardTest extends TestCase {
test_a_battleship_shells_a_defenceless_coastal_city() {
const state = smallState();
const city = state.cities.find(
(c) => c.civ === 1 && state.isCoastalCity(c) && state.unitsAt(c.coords).length === 0
);
this.assertNotNull(city, "the enemy has a defenceless coastal city");
// The generated map does not promise an enemy coastal city, so move one
// onto a free stretch of coast: the test is about the shelling, not where
// the city happened to be founded.
const city = state.cities.find((c) => c.civ === 1);
const spot = freeSeaNearCoast(state);
this.assertNotNull(spot, "the map has an open coast");
city.coords = { x: spot.land.x, y: spot.land.y };
state._rebuildCityIndex();
state.tilePopulation.set(key(city.coords.x, city.coords.y), 500000);
this.assertTrue(state.isCoastalCity(city), "the relocated enemy city is coastal");
const sea = freeSeaNear(state, city.coords, 2);
this.assertNotNull(sea, "there is open water within the guns' reach of the city");
const battleship = state._spawnUnit(sea, 0, protoUnit(state, "battleship"));
+9
View File
@@ -55,6 +55,15 @@ function makeServer(civIds = ["france", "britain"]) {
const network = fakeNetwork();
const server = new GameServer(network);
server.configureGame({ seed: SEED, player_civ: 0, civilisations: civIds });
// These tests stage a unit on neutral coast next to foreign territory, which
// needs the two nations to share a landmass; regenerate the fixture as a
// single continent, with no small-island nations, so such a coast exists.
server.state.mapConfig = {
...server.state.mapConfig,
minContinents: 1,
smallIslandCount: 0,
};
server.state.configure(server.state.civilisations, SEED);
return { server, network };
}
+3
View File
@@ -29,6 +29,9 @@ export function smallConfig(overrides = {}) {
minCapitalDistance: 2,
citiesPerCiv: 2,
citySpacing: 2,
// The fixtures stay one continent: splitting them changes where capitals
// and cities fall, which the tests pin down.
minContinents: 1,
...overrides,
};
}
+2 -2
View File
@@ -249,7 +249,7 @@ export class GameServerTest extends TestCase {
test_train_orders_queue_for_own_city() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities[0];
const city = server.state.cities.find((c) => c.civ === 0);
grantBuilding(server.state, city, "barracks");
const order = { type: "train", city: city.id, proto: 0 };
this.assertTrue(server.handleOrder(1, order));
@@ -260,7 +260,7 @@ export class GameServerTest extends TestCase {
test_cancel_train_order_for_own_city_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities[0];
const city = server.state.cities.find((c) => c.civ === 0);
grantBuilding(server.state, city, "barracks");
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
+80 -2
View File
@@ -10,6 +10,8 @@ function config(overrides = {}) {
mapSize: { x: 32, y: 24 },
minCapitalLandmass: 1,
minCapitalDistance: 2,
// The fixture stays one continent unless a test asks for the guarantee.
minContinents: 1,
...overrides,
};
}
@@ -54,6 +56,31 @@ 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) {
@@ -203,12 +230,12 @@ export class MapGeneratorTest extends TestCase {
}
test_raw_terrain_can_have_separate_seas() {
const separate = seaBodies(generate(config({ connectOceans: false, terrainSeed: 1 })));
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: 1 })));
const bodies = seaBodies(generate(config({ connectOceans: true, terrainSeed: 4 })));
this.assertEqual(bodies, 1, "one connected ocean");
}
@@ -272,4 +299,55 @@ export class MapGeneratorTest extends TestCase {
"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`);
}
}
}