diff --git a/client/js/map_view.js b/client/js/map_view.js index 60a37b5..54f7864 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -48,6 +48,10 @@ export class MapView { } this.seed = -1; + // Signature of the terrain currently built, so a snapshot with the same + // seed and config does not rebuild the world. + this._terrainSignature = null; + this.mapConfig = null; this.topology = null; this.tiles = {}; this.terrainStats = null; diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 294878e..93c6131 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -13,7 +13,7 @@ export const entityMethods = { }, applySnapshot(state) { - this.ensureTerrain(state.seed); + this.ensureTerrain(state.seed, state.mapConfig || null); this.setRoads(state.roads || []); this.localCiv = state.viewer; this._applyTerritory(state.territory); diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 2ee24ca..9668b69 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -18,16 +18,23 @@ import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIM import { overviewColour, blendColours } from "./utils.js"; export const terrainMethods = { - ensureTerrain(seed) { - if (seed === this.seed) return; + ensureTerrain(seed, mapConfig = null) { + // The snapshot carries the config the seed was generated with; fall back to + // the shared default for callers that predate it. A signature rather than a + // reference test keeps a fresh snapshot object from rebuilding the world. + const config = mapConfig || MAP_CONFIG; + const signature = `${seed}|${JSON.stringify(config)}`; + if (signature === this._terrainSignature) return; + this._terrainSignature = signature; this.seed = seed; + this.mapConfig = config; // A new world brings a new road network; drop the old one (and the cache // signature) before the chunks are rebuilt, so setRoads always installs the // new tiles. this.roads = new Set(); this._roadsSignature = null; - this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical"); - const generator = new MapGenerator(MAP_CONFIG, this.topology); + this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical"); + const generator = new MapGenerator(config, this.topology); generator.generate(seed); this.tiles = generator.tiles; this.terrainStats = new TerrainStats(this.tiles); diff --git a/shared/game_state.js b/shared/game_state.js index 6d7fe6d..410a02b 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -9,9 +9,7 @@ // methods can freely call each other through `this`; the split is purely about // keeping each concern in a file small enough to read. -import { MapTopology, key } from "./hex.js"; -import { MapGenerator } from "./map_generator.js"; -import { TerrainStats } from "./terrain_stats.js"; +import { key } from "./hex.js"; import { HexPathfinder } from "./hex_pathfinder.js"; import { randomSeed } from "./rng.js"; import { @@ -158,17 +156,7 @@ export class GameState { configure(civs, worldSeed = 0) { this.civilisations = civs.slice(); this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed(); - this.topology = new MapTopology( - this.mapConfig.mapSize, - this.mapConfig.topology === "cylindrical" - ); - this._generator = new MapGenerator(this.mapConfig, this.topology); - this._generator.generate(this.seed); - this.tiles = this._generator.tiles; - this.landCells = this._generator.landCells; - this.terrainStats = new TerrainStats(this.tiles); - this._minMovementCost = this.terrainStats.minMovementCostMultiplier(); - this._maxStepLength = this._computeMaxStepLength(); + this._buildTerrain(); this._resetCollections(); for (let i = 0; i < this.civilisations.length; i++) { diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index cf89f56..1823f7f 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -12,6 +12,10 @@ export const serializationMethods = { serializeShared() { return { seed: this.seed, + // The world-generation config the seed was generated with. The browser + // rebuilds the same terrain from it, so a game on a non-default map size + // or shape is rendered correctly instead of falling back to the default. + mapConfig: this.mapConfig, totalHours: this.totalHours, civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })), protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })), diff --git a/shared/game_state/world.js b/shared/game_state/world.js index 3361799..6fcaf12 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -5,15 +5,90 @@ import { ECONOMY } from "../data/economy.js"; import { ROADS } from "../data/roads.js"; import { buildRoadNetwork } from "../roads.js"; +import { MapTopology, key, parseKey } from "../hex.js"; +import { MapGenerator } from "../map_generator.js"; +import { TerrainStats } from "../terrain_stats.js"; import { Random } from "../rng.js"; -import { key, parseKey } from "../hex.js"; + +// World generation is a pure function of (seed, mapConfig): the same inputs +// always produce the same terrain, city sites and road network. Caching the +// shape lets a new game with a known seed -- a server restart, or one test +// fixture after another -- skip regenerating the whole world every time. Only +// the immutable terrain pieces are shared; every mutable collection is rebuilt +// per GameState. +const WORLD_CACHE_LIMIT = 16; +const terrainCache = new Map(); +const settlementCache = new Map(); + +function cachePut(cache, cacheKey, value) { + cache.set(cacheKey, value); + if (cache.size > WORLD_CACHE_LIMIT) cache.delete(cache.keys().next().value); +} + +function copyCoords(coords) { + return { x: coords.x, y: coords.y }; +} export const worldMethods = { + // Builds or reuses the topology, tiles and terrain stats for `this.seed` and + // `this.mapConfig`. + _buildTerrain() { + const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}`; + const cached = terrainCache.get(cacheKey); + if (cached) { + this.topology = cached.topology; + this.tiles = cached.tiles; + this.landCells = cached.landCells; + this.terrainStats = cached.terrainStats; + this._generator = cached.generator; + this._minMovementCost = cached.minMovementCost; + this._maxStepLength = cached.maxStepLength; + return; + } + this.topology = new MapTopology( + this.mapConfig.mapSize, + this.mapConfig.topology === "cylindrical" + ); + this._generator = new MapGenerator(this.mapConfig, this.topology); + this._generator.generate(this.seed); + this.tiles = this._generator.tiles; + this.landCells = this._generator.landCells; + this.terrainStats = new TerrainStats(this.tiles); + this._minMovementCost = this.terrainStats.minMovementCostMultiplier(); + this._maxStepLength = this._computeMaxStepLength(); + cachePut(terrainCache, cacheKey, { + topology: this.topology, + tiles: this.tiles, + landCells: this.landCells, + terrainStats: this.terrainStats, + generator: this._generator, + minMovementCost: this._minMovementCost, + maxStepLength: this._maxStepLength, + }); + }, + _placeSettlements() { - const capitals = this._placeCapitals(this.civilisations.length); - for (const site of this._placeCities(capitals)) this._spawnCity(site); + // City sites and the road network depend only on the seed, the map config + // and how many nations are playing, so they are cached alongside the + // terrain and reused when a known world is started again. + const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}|${this.civilisations.length}`; + const cached = settlementCache.get(cacheKey); + const capitals = cached + ? cached.capitals.map(copyCoords) + : this._placeCapitals(this.civilisations.length); + const sites = cached + ? cached.sites.map((site) => ({ ...site, coords: copyCoords(site.coords) })) + : this._placeCities(capitals); + for (const site of sites) this._spawnCity(site); this._rebuildCityIndex(); - this._generateRoads(); + this._generateRoads(cached ? cached.roads : null); + if (!cached) { + cachePut(settlementCache, cacheKey, { + capitals: capitals.map(copyCoords), + sites: sites.map((site) => ({ ...site, coords: copyCoords(site.coords) })), + roads: new Set(this.roads), + }); + } for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) { this._spawnUnit(capitals[i], i, this.protoUnits[0]); } @@ -99,13 +174,17 @@ export const worldMethods = { // Lays the pre-generated road network. Cities are founded with a road, so // they are free nodes while the network is drawn; the chosen links are the // greedy spanner over every possible city-to-city route. - _generateRoads() { + _generateRoads(roadKeys = null) { for (const city of this.cities) { if (!city.improvements.includes(ROADS.improvementName)) { city.improvements.push(ROADS.improvementName); } } - this.roads = buildRoadNetwork(this.cities, this.topology, this.tiles); + // A cached world hands back the (read-only) road tiles; copy them so no + // state can alter the cache. + this.roads = roadKeys + ? new Set(roadKeys) + : buildRoadNetwork(this.cities, this.topology, this.tiles); }, _spawnUnit(center, civIndex, proto, homeCityId = null) { diff --git a/shared/roads.js b/shared/roads.js index 1dc9208..2806c89 100644 --- a/shared/roads.js +++ b/shared/roads.js @@ -76,11 +76,17 @@ class MinHeap { // Dijkstra over the land tiles from `source`, costing each step through // `stepCost(coords, tile)`. Returns the distance to every reachable tile and // the parent needed to rebuild the route. -export function shortestPaths(source, topology, tiles, stepCost) { +// +// `targets` (optional) is a set of canonical keys the caller actually needs: +// once every one of them is settled the search stops, which keeps the +// road-network build from flooding the whole continent from each city. +export function shortestPaths(source, topology, tiles, stepCost, targets = null) { const sourceKey = key(source.x, source.y); const distance = new Map([[sourceKey, 0]]); const parent = new Map(); const settled = new Set(); + const remaining = targets ? new Set(targets) : null; + if (remaining) remaining.delete(sourceKey); const heap = new MinHeap(); heap.push(0, source); while (!heap.isEmpty()) { @@ -88,6 +94,10 @@ export function shortestPaths(source, topology, tiles, stepCost) { const currentKey = key(current.x, current.y); if (settled.has(currentKey)) continue; settled.add(currentKey); + if (remaining) { + remaining.delete(currentKey); + if (remaining.size === 0 && distance.has(currentKey)) break; + } for (const neighbour of topology.neighbours(current.x, current.y)) { const nk = key(neighbour.x, neighbour.y); const tile = tiles[nk]; @@ -154,8 +164,18 @@ export function buildRoadNetwork(cities, topology, tiles, options = {}) { for (const k of cityKeys) roads.add(k); if (cities.length < 2) return roads; - // Every possible link, with the cheapest route and its cost. - const searches = cities.map((city) => shortestPaths(city.coords, topology, tiles, stepCost)); + // Every possible link, with the cheapest route and its cost. Each search may + // stop as soon as it has reached every other city, instead of flooding the + // whole continent. + const searches = cities.map((city, index) => + shortestPaths( + city.coords, + topology, + tiles, + stepCost, + cityKeys.filter((_, other) => other !== index) + ) + ); const edges = []; for (let a = 0; a < cities.length; a++) { for (let b = a + 1; b < cities.length; b++) { diff --git a/tests/app_test.js b/tests/app_test.js index 1cd8fd5..8f49e61 100644 --- a/tests/app_test.js +++ b/tests/app_test.js @@ -1,7 +1,7 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.js"; import { CIVILISATIONS } from "../shared/data.js"; -import { defaultState } from "./framework/helpers.js"; +import { smallState } from "./framework/helpers.js"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -48,7 +48,7 @@ export class AppTest extends TestCase { joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 }); this.assertTrue(env.$("#screen-game").hasClass("active")); - joinSocket.simulateMessage({ t: "game_state", state: defaultState().snapshot(1) }); + joinSocket.simulateMessage({ t: "game_state", state: smallState().snapshot(1) }); this.assertEqual(env.$("#civ-name").text(), "Britain"); this.assertFalse(env.$("#stat-pop").text() === "0"); } finally { @@ -135,7 +135,7 @@ export class AppTest extends TestCase { this.assertEqual(setupMessage.setup.player_civ, 0); this.assertEqual(setupMessage.setup.civilisations, ["france", "britain", "slovenia"]); - socket.simulateMessage({ t: "game_state", state: defaultState(["france", "britain", "slovenia"], 4242).snapshot(0) }); + socket.simulateMessage({ t: "game_state", state: smallState(["france", "britain", "slovenia"], 4242).snapshot(0) }); this.assertEqual(env.$("#civ-name").text(), "France"); this.assertFalse(env.$("#stat-pop").text() === "0"); } finally { diff --git a/tests/combat_test.js b/tests/combat_test.js index a504b11..867c299 100644 --- a/tests/combat_test.js +++ b/tests/combat_test.js @@ -1,6 +1,6 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; -import { smallState, defaultState, mapLayers } from "./framework/helpers.js"; +import { smallState, mapLayers } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js"; import { MapView } from "../client/js/map_view.js"; @@ -488,7 +488,7 @@ export class CombatTest extends TestCase { } async test_client_prediction_mirrors_occupancy() { - const state = defaultState(); + const state = smallState(); const pair = findEmptyLandPair(state); const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); state._spawnUnit(pair.b, 1, state.protoUnits[0]); @@ -529,7 +529,7 @@ export class CombatTest extends TestCase { } async test_client_prediction_charges_onto_the_target() { - const state = defaultState(); + const state = smallState(); const pair = findEmptyLandPair(state); const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); state._spawnUnit(pair.b, 1, state.protoUnits[0]); @@ -553,7 +553,7 @@ export class CombatTest extends TestCase { } async test_client_animates_the_charge_onto_the_enemy() { - const state = defaultState(); + const state = smallState(); const pair = findEmptyLandPair(state); const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); state._spawnUnit(pair.b, 1, state.protoUnits[0]); @@ -577,7 +577,7 @@ export class CombatTest extends TestCase { } async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() { - const state = defaultState(); + const state = smallState(); const { b } = engagement(state); const env = await setupDom(); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 073d5ab..f022a60 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { GameScreen } from "../client/js/game_screen.js"; import { BUILDINGS, CIVILISATIONS } from "../shared/data.js"; -import { defaultState, adjacentLand } from "./framework/helpers.js"; +import { smallState, adjacentLand } from "./framework/helpers.js"; function stubNetwork() { return { @@ -37,7 +37,7 @@ export class GameScreenTest extends TestCase { screen.enter(); this.assertTrue(env.$("#screen-game").hasClass("active")); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); this.assertEqual(env.$("#civ-name").text(), "France"); this.assertEqual(env.$("#player-name").text(), "Carol"); @@ -77,7 +77,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - screen.onState(defaultState().snapshot(0)); + screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertSize(env.$("#government-list .row-card"), 3); @@ -93,7 +93,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); state.budgets.set(0, 1.0e15); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); @@ -120,7 +120,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); @@ -143,7 +143,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); state.budgets.set(0, 1.0e15); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); @@ -173,7 +173,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); const raw = state.units.find((u) => u.civ === 0); const unit = screen._unit(raw.id); @@ -204,7 +204,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const home = state.units.find((u) => u.civ === 0); const artillery = state._spawnUnit( home.coords, @@ -268,7 +268,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onTileRequested(capital.coords); @@ -320,7 +320,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - screen.onState(defaultState().snapshot(0)); + screen.onState(smallState().snapshot(0)); const before = screen.map.camera.x; env.$(env.window).trigger(env.$.Event("keydown", { key: "d" })); @@ -353,7 +353,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); state.research.set(0, 1.0e6); state.budgets.set(0, 1.0e15); const method = state.technologies.findIndex((t) => t.id === "scientific_method"); @@ -380,7 +380,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const mountain = state.landCells.find( (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain" ); @@ -418,7 +418,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const plain = state.landCells.find( (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" && !state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n)) @@ -448,7 +448,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); const goal = { x: 900, y: 900 }; @@ -478,7 +478,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); const goal = { x: 902, y: 902 }; @@ -499,7 +499,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); state.requestDeclareWar(0, 1); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); @@ -522,7 +522,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - screen.onState(defaultState().snapshot(0)); + screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab=diplomacy]").click(); const row = env.$("#diplomacy-list tbody tr").first(); @@ -541,7 +541,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); state.requestDeclareWar(0, 1); screen.onState(state.snapshot(0)); env.$("#news-button").click(); @@ -558,7 +558,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); // The first snapshot is history: it must not flood the feed. screen.onState(state.snapshot(0)); this.assertSize(env.$("#game-feed .feed-line"), 0); @@ -578,7 +578,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); screen.onState(state.snapshot(0)); state.requestDeclareWar(1, 0); @@ -598,7 +598,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); @@ -627,7 +627,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); @@ -659,7 +659,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const capital = state.units.find((u) => u.civ === 0); const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]); const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]); @@ -686,7 +686,7 @@ export class GameScreenTest extends TestCase { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const coords = findEnemyTile(state); this.assertNotNull(coords, "found an enemy tile"); state.tilePopulation.set(`${coords.x},${coords.y}`, 100000); @@ -717,7 +717,7 @@ export class GameScreenTest extends TestCase { try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); screen.onState(state.snapshot(0)); screen._selectUnits([unit.id]); diff --git a/tests/game_state_test.js b/tests/game_state_test.js index f5d372f..7bf4e5d 100644 --- a/tests/game_state_test.js +++ b/tests/game_state_test.js @@ -39,6 +39,22 @@ export class GameStateTest extends TestCase { this.assertEqual(a.territory, b.territory); } + // The generated world is cached and reused for a repeated seed, so the + // mutable pieces must be rebuilt per game rather than shared. + test_reused_worlds_are_independent() { + const a = smallState(); + const b = smallState(); + const roads = b.roads.size; + const population = b.cities[0].population; + a.roads.add("999999,999999"); + a.cities[0].population = -1; + a.units[0].coords = { x: 0, y: 0 }; + this.assertSize(b.roads, roads, "the cached roads are copied per game"); + this.assertNotEqual(b.cities[0].population, -1, "cities are not shared"); + this.assertNotEqual(b.units[0].coords, a.units[0].coords, "units are not shared"); + this.assertEqual(b.cities[0].population, population, "the other game is untouched"); + } + test_foreign_territory_is_a_wall_unless_targeted() { const state = smallState(["france", "britain", "poland"]); const mover = state.units.find((u) => u.civ === 0); diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 06de082..20cad39 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -3,7 +3,7 @@ import { setupDom, teardownDom } from "./framework/dom.js"; import { MapView, inwardOffset } from "../client/js/map_view.js"; import { mapToLocal, COL_STEP } from "../shared/hex.js"; import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js"; -import { defaultState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js"; +import { defaultState, smallState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js"; export class MapViewTest extends TestCase { async build(state) { @@ -18,6 +18,8 @@ export class MapViewTest extends TestCase { } async test_renders_terrain_territory_fog_and_entities() { + // A full-size map, so the viewport genuinely covers only part of it and the + // chunking is exercised; the other view tests use the small fixture. const state = defaultState(); const { env, map, snapshot } = await this.build(state); try { @@ -39,7 +41,7 @@ export class MapViewTest extends TestCase { } async test_renders_the_pre_generated_road_network() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored"); @@ -53,7 +55,7 @@ export class MapViewTest extends TestCase { } async test_unit_views_show_their_owners_flag() { - const state = defaultState(); + const state = smallState(); const { env } = await this.build(state); try { const flags = env.$("#layer-entities .unit .unit-flag"); @@ -68,7 +70,7 @@ export class MapViewTest extends TestCase { } async test_centers_on_the_capital() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); @@ -82,7 +84,7 @@ export class MapViewTest extends TestCase { } async test_tile_at_screen_round_trips() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); @@ -96,7 +98,7 @@ export class MapViewTest extends TestCase { } async test_wrapped_tile_lookup_stays_in_range() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const coords = map.tileAtScreen(100000, 300); @@ -109,7 +111,7 @@ export class MapViewTest extends TestCase { } async test_terrain_wraps_across_the_seam() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { this.assertGreater(map._period, 0, "the map has a wrap period"); @@ -134,7 +136,7 @@ export class MapViewTest extends TestCase { } async test_chunks_meet_seamlessly_at_the_wrap() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { // Sit on the seam (just past the east edge) so both the east-edge chunk @@ -166,7 +168,7 @@ export class MapViewTest extends TestCase { } async test_dragging_wraps_with_transforms_not_layout() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const rec = map._chunks.values().next().value; @@ -186,7 +188,7 @@ export class MapViewTest extends TestCase { } async test_wrap_shift_picks_the_copy_nearest_the_camera() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const period = map._period; @@ -201,7 +203,7 @@ export class MapViewTest extends TestCase { } async test_zooming_out_switches_to_the_overview() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { this.assertFalse(map.lod, "detailed at close zoom"); @@ -240,7 +242,7 @@ export class MapViewTest extends TestCase { } async test_neighbouring_borders_are_inset_not_overlaid() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { // Bring the two-tile territory into view so its chunk is materialised. @@ -267,7 +269,7 @@ export class MapViewTest extends TestCase { } async test_selecting_a_unit_marks_its_view() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); @@ -280,7 +282,7 @@ export class MapViewTest extends TestCase { } async test_predict_move_starts_a_local_route() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); @@ -300,7 +302,7 @@ export class MapViewTest extends TestCase { } async test_plan_move_computes_a_route_without_predicting() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); @@ -321,7 +323,7 @@ export class MapViewTest extends TestCase { } async test_plan_move_reuses_a_shared_route() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); @@ -344,7 +346,7 @@ export class MapViewTest extends TestCase { } async test_pan_moves_the_camera() { - const state = defaultState(); + const state = smallState(); const { env, map } = await this.build(state); try { const beforeX = map.camera.x; @@ -358,7 +360,7 @@ export class MapViewTest extends TestCase { } async test_syncs_and_draws_an_authoritative_path() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); state.requestMove(unit.id, goal); @@ -417,7 +419,7 @@ export class MapViewTest extends TestCase { } async test_right_click_on_a_unit_icon_orders_the_selected_stack() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); const target = state._spawnUnit(goal, 0, state.protoUnits[0]); @@ -448,7 +450,7 @@ export class MapViewTest extends TestCase { } async test_left_click_selects_city_or_clears() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { let chosen = null; @@ -478,7 +480,7 @@ export class MapViewTest extends TestCase { } async test_city_marker_click_reports_the_city() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { let chosen = null; @@ -495,7 +497,7 @@ export class MapViewTest extends TestCase { } async test_clear_selection_notifies() { - const state = defaultState(); + const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); @@ -513,7 +515,7 @@ export class MapViewTest extends TestCase { } async test_stacked_units_show_one_icon_with_a_count_badge() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const { env, map } = await this.build(state); @@ -531,7 +533,7 @@ export class MapViewTest extends TestCase { } async test_right_click_on_a_friendly_stack_opens_the_menu() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const { env, map } = await this.build(state); @@ -551,7 +553,7 @@ export class MapViewTest extends TestCase { } async test_stack_ids_group_units_sharing_a_tile() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const { env, map } = await this.build(state); @@ -564,7 +566,7 @@ export class MapViewTest extends TestCase { } async test_right_click_on_a_friendly_stack_while_selected_moves_there() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const { env, map } = await this.build(state); @@ -584,7 +586,7 @@ export class MapViewTest extends TestCase { } } - async test_right_click_on_an_enemy_stack_attacks_it() { const state = defaultState(); + async test_right_click_on_an_enemy_stack_attacks_it() { const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); state._spawnUnit(goal, 1, state.protoUnits[0]); @@ -605,7 +607,7 @@ export class MapViewTest extends TestCase { } async test_ctrl_right_click_schedules_a_destination() { - const state = defaultState(); + const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); const { env, map } = await this.build(state); diff --git a/tests/run_tests.js b/tests/run_tests.js index 8c62049..2d2f534 100644 --- a/tests/run_tests.js +++ b/tests/run_tests.js @@ -1,127 +1,176 @@ -// Test runner. Instantiates every suite, calls its `test_*` methods and prints -// a summary, exiting non-zero when anything fails. Run with: +// Test runner. Every `tests/*_test.js` file is discovered automatically and run +// as a suite. By default the files are farmed out across a pool of worker +// processes, so the whole run is bounded by the slowest file rather than their +// sum. A single file can be run in-process for debugging. // -// node tests/run_tests.js +// node tests/run_tests.js # all files, parallel +// node tests/run_tests.js --jobs 4 # cap the worker pool +// node tests/run_tests.js --file map_view_test.js +// +// In worker mode (`--file`) the file's exported TestCase classes are run and a +// single machine-readable result line is printed for the parent to collect. -import { HexTest } from "./hex_test.js"; -import { NoiseTest } from "./noise_test.js"; -import { RngTest } from "./rng_test.js"; -import { DataTest } from "./data_test.js"; -import { MapGeneratorTest } from "./map_generator_test.js"; -import { HexPathfinderTest } from "./hex_pathfinder_test.js"; -import { TerrainStatsTest } from "./terrain_stats_test.js"; -import { RoadsTest } from "./roads_test.js"; -import { EconomyTest } from "./economy_test.js"; -import { TextFormatTest } from "./text_format_test.js"; -import { GameClockTest } from "./game_clock_test.js"; -import { LoginManagerTest } from "./login_manager_test.js"; -import { RulesTest } from "./rules_test.js"; -import { CombatTest } from "./combat_test.js"; -import { StackingTest, PillageTest } from "./stacking_test.js"; -import { SiegeTest } from "./siege_test.js"; -import { AttackOrderTest, ScheduledOrderTest } from "./orders_test.js"; -import { GameStateTest } from "./game_state_test.js"; -import { GameStateBuildingsTest } from "./game_state_buildings_test.js"; -import { GameStateGovernmentTest } from "./game_state_government_test.js"; -import { GameStateTechnologyTest } from "./game_state_technology_test.js"; -import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js"; -import { ConflictsStateTest, ConflictsModalTest } from "./conflicts_test.js"; -import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js"; -import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js"; -import { WebSocketProtocolTest } from "./websocket_test.js"; -import { NetTest } from "./net_test.js"; -import { MapViewTest } from "./map_view_test.js"; -import { AirTest } from "./air_test.js"; -import { CityModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js"; -import { GameScreenTest } from "./game_screen_test.js"; -import { GameFeedTest } from "./feed_test.js"; -import { AppTest } from "./app_test.js"; -import { - OrderStateRegressionTest, - EconomyOrderEdgesTest, - AttritionTest, - CasualtyRecordingTest, - CatalogueSplitTest, - TextFormatRegressionTest, -} from "./regression_test.js"; +import { readdirSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { cpus } from "node:os"; -const SUITES = [ - HexTest, - NoiseTest, - RngTest, - DataTest, - MapGeneratorTest, - HexPathfinderTest, - TerrainStatsTest, - RoadsTest, - EconomyTest, - TextFormatTest, - GameClockTest, - LoginManagerTest, - RulesTest, - CombatTest, - StackingTest, - PillageTest, - SiegeTest, - AttackOrderTest, - ScheduledOrderTest, - GameStateTest, - GameStateBuildingsTest, - GameStateGovernmentTest, - GameStateTechnologyTest, - DiplomacyStateTest, - DiplomacyServerTest, - ConflictsStateTest, - ConflictsModalTest, - GameServerTest, - GameServerOrdersTest, - GameServerLoginTest, - GameServerLobbyTest, - ServerStaticTest, - ServerLoopTest, - ServerProtocolTest, - WebSocketProtocolTest, - NetTest, - MapViewTest, - AirTest, - CityModalTest, - NationModalTest, - ConfirmModalTest, - NewsModalTest, - GameScreenTest, - GameFeedTest, - AppTest, - OrderStateRegressionTest, - EconomyOrderEdgesTest, - AttritionTest, - CasualtyRecordingTest, - CatalogueSplitTest, - TextFormatRegressionTest, -]; +const HERE = dirname(fileURLToPath(import.meta.url)); +const SELF = fileURLToPath(import.meta.url); +const RESULT_MARKER = "__TEST_RESULT__"; +const MAX_JOBS = 8; -let passed = 0; -const failures = []; +function flagValue(args, flag) { + const inline = args.find((arg) => arg.startsWith(`${flag}=`)); + if (inline) return inline.slice(flag.length + 1); + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : null; +} -for (const Suite of SUITES) { - const instance = new Suite(); - const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(instance)) - .filter((name) => name.startsWith("test_") && name !== "constructor") +// Every test file in this directory, in a stable order. +function discoverFiles() { + return readdirSync(HERE) + .filter((name) => name.endsWith("_test.js")) .sort(); - for (const method of methods) { - try { - await instance[method](); - passed += 1; - } catch (error) { - failures.push(`${Suite.name}::${method}: ${error.message}`); +} + +// The TestCase classes a module exports, with their sorted test methods. Using +// the exports rather than a hand-written list means a new test file is picked +// up with no runner edit. +function testClasses(module) { + const classes = []; + for (const value of Object.values(module)) { + if (typeof value !== "function" || !value.prototype) continue; + const methods = Object.getOwnPropertyNames(value.prototype) + .filter((name) => name.startsWith("test_") && name !== "constructor") + .sort(); + if (methods.length > 0) classes.push({ ctor: value, methods }); + } + return classes; +} + +async function runFile(file) { + const started = Date.now(); + const module = await import(new URL(file, import.meta.url)); + let passed = 0; + const failures = []; + for (const { ctor, methods } of testClasses(module)) { + for (const method of methods) { + const instance = new ctor(); + try { + await instance[method](); + passed += 1; + } catch (error) { + failures.push(`${ctor.name}::${method}: ${error.message}`); + } } } + return { file, passed, failures, ms: Date.now() - started }; } -console.log(""); -if (failures.length === 0) { - console.log(`PASS: ${passed} test(s) passed.`); - process.exit(0); +// Worker mode: run one file, then exit. When spawned by the parent it prints a +// machine-readable marker; run by hand it prints the usual summary. The write +// callback guarantees output reaches the parent before the process ends. +async function runWorker(file) { + const result = await runFile(file); + const code = result.failures.length === 0 ? 0 : 1; + if (process.env.TISMO_WORKER === "1") { + const line = `\n${RESULT_MARKER}${JSON.stringify(result)}\n`; + process.stdout.write(line, () => process.exit(code)); + return; + } + console.log(""); + if (result.failures.length === 0) console.log(`PASS: ${result.passed} test(s) passed.`); + else { + console.log(`FAIL: ${result.passed} passed, ${result.failures.length} failed.`); + for (const failure of result.failures) console.log(` - ${failure}`); + } + process.exit(code); } -console.log(`FAIL: ${passed} passed, ${failures.length} failed.`); -for (const failure of failures) console.log(` - ${failure}`); -process.exit(1); + +function runChild(file) { + return new Promise((resolve) => { + const started = Date.now(); + const child = spawn(process.execPath, [SELF, "--file", file], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, TISMO_WORKER: "1" }, + }); + let out = ""; + let err = ""; + child.stdout.on("data", (chunk) => (out += chunk)); + child.stderr.on("data", (chunk) => (err += chunk)); + child.on("close", (code) => { + const index = out.lastIndexOf(RESULT_MARKER); + if (index >= 0) { + const line = out.slice(index + RESULT_MARKER.length).split("\n")[0]; + try { + resolve(JSON.parse(line)); + return; + } catch { + // fall through to the failure record below + } + } + resolve({ + file, + passed: 0, + failures: [`${file}: worker produced no result (exit ${code}) ${err.trim()}`], + ms: Date.now() - started, + }); + }); + }); +} + +// Runs `worker` over `items`, never more than `jobs` at a time. Results keep +// their input order so the summary is deterministic. +async function pool(items, jobs, worker) { + const results = new Array(items.length); + let next = 0; + const run = async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await worker(items[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(jobs, items.length) }, run)); + return results; +} + +async function main() { + const args = process.argv.slice(2); + const fileArg = flagValue(args, "--file"); + if (fileArg) { + await runWorker(fileArg); + return; + } + + const requested = Number(flagValue(args, "--jobs")) || cpus().length; + const jobs = Math.max(1, Math.min(requested, MAX_JOBS)); + const files = discoverFiles(); + const results = await pool(files, jobs, runChild); + + let passed = 0; + const failures = []; + for (const result of results) { + passed += result.passed; + failures.push(...result.failures); + } + + console.log(""); + for (const result of results.slice().sort((a, b) => b.ms - a.ms)) { + const seconds = (result.ms / 1000).toFixed(1).padStart(5); + const status = result.failures.length === 0 ? "ok " : "FAIL"; + console.log(`${status} ${seconds}s ${result.file}`); + } + console.log(""); + if (failures.length === 0) { + console.log(`PASS: ${passed} test(s) passed.`); + process.exit(0); + } + console.log(`FAIL: ${passed} passed, ${failures.length} failed.`); + for (const failure of failures) console.log(` - ${failure}`); + process.exit(1); +} + +main(); diff --git a/tests/siege_test.js b/tests/siege_test.js index 206e62e..6641ac4 100644 --- a/tests/siege_test.js +++ b/tests/siege_test.js @@ -1,6 +1,6 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; -import { smallState, defaultState, mapLayers, SEED } from "./framework/helpers.js"; +import { smallState, mapLayers, SEED } from "./framework/helpers.js"; import { parseKey } from "../shared/hex.js"; import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js"; import { MapView } from "../client/js/map_view.js"; @@ -168,7 +168,7 @@ export class SiegeTest extends TestCase { } async test_the_client_draws_status_icons_over_units_and_cities() { - const state = defaultState(); + const state = smallState(); const city = findBesiegableCity(state, 0); this.assertNotNull(city, "found a city to besiege"); const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);