diff --git a/shared/data/map_config.js b/shared/data/map_config.js index 726e7f6..fa598ca 100644 --- a/shared/data/map_config.js +++ b/shared/data/map_config.js @@ -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). diff --git a/shared/game_state.js b/shared/game_state.js index 74dcbdf..113182e 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -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. diff --git a/shared/map_generator.js b/shared/map_generator.js index 214fc2e..97d8dfb 100644 --- a/shared/map_generator.js +++ b/shared/map_generator.js @@ -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; diff --git a/tests/air_test.js b/tests/air_test.js index 30a21d7..a1e46ac 100644 --- a/tests/air_test.js +++ b/tests/air_test.js @@ -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")); diff --git a/tests/diplomacy_test.js b/tests/diplomacy_test.js index 1a52200..34915a3 100644 --- a/tests/diplomacy_test.js +++ b/tests/diplomacy_test.js @@ -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 }; } diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js index 3d3d683..d46df36 100644 --- a/tests/framework/helpers.js +++ b/tests/framework/helpers.js @@ -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, }; } diff --git a/tests/game_server_test.js b/tests/game_server_test.js index 02bd44d..93a1e44 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -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 }); diff --git a/tests/map_generator_test.js b/tests/map_generator_test.js index 1c8fbbf..3833852 100644 --- a/tests/map_generator_test.js +++ b/tests/map_generator_test.js @@ -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`); + } + } }