import { teardownDom } from "./framework/dom.js"; import { MapViewFixture } from "./framework/map_view_fixture.js"; import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js"; import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js"; import { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js"; import { defaultState, smallState, adjacentLand, seaTile, grantBuilding, stripPorts } from "./framework/helpers.js"; export class MapViewRenderTest extends MapViewFixture { 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 { const rendered = []; for (const rec of map._chunks.values()) rendered.push(...rec.tileKeys); this.assertGreater(rendered.length, 0, "chunks near the camera are built"); this.assertLess(rendered.length, Object.keys(map.tiles).length, "the whole map is never built"); this.assertSize(env.$("#layer-terrain .hex"), rendered.length); const visibleRendered = rendered.filter((k) => map.visible.has(k)).length; this.assertSize(env.$("#layer-fog .hex"), rendered.length - visibleRendered); this.assertGreater(env.$("#layer-borders .border-seg").length, 0); this.assertSize(map.territory, snapshot.territory.length); this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length); this.assertSize(env.$("#layer-entities .unit"), snapshot.units.length); this.assertEqual(map.localCiv, 0); } finally { teardownDom(env); } } async test_a_site_warns_only_when_no_supplier_is_in_reach() { const state = smallState(); const { env, map } = await this.build(state); try { const coords = [2, 1]; map.setConstructionSites([[{ coords, phase: "materials", stalled: true, unreachable: [], elapsedHours: 0, totalHours: 10, }]]); let marker = env.$("#layer-entities .construction-marker").eq(0); this.assertEqual(marker.text(), "", "a reachable stalled site keeps no exclamation"); this.assertEqual(marker.find(".gather-spinner").length, 1, "it keeps working"); map.setConstructionSites([[{ coords, phase: "materials", stalled: true, unreachable: ["steel"], elapsedHours: 0, totalHours: 10, }]]); marker = env.$("#layer-entities .construction-marker").eq(0); this.assertEqual(marker.text(), "!", "only an unreachable site warns"); this.assertTrue(marker.hasClass("stalled"), "and the marker is flagged stalled"); } finally { teardownDom(env); } } async test_dom_terrain_uses_the_tiling_land_textures() { const state = smallState(); const { env, map } = await this.build(state); try { const local = mapToLocal(2, 1); const land = env.window.document.createElement("div"); map._applyTerrainTexture(land, { terrainType: "Land", col: 3 }, local); this.assertTrue(land.style.backgroundImage.includes("textures/grass.png"), "plains use grass"); // Positioned from the tile's world origin, so neighbours continue the pattern. this.assertTrue( land.style.backgroundPosition.includes(`${-(local.x - HEX_W / 2)}px`), `world-positioned tiling: ${land.style.backgroundPosition}` ); const forest = env.window.document.createElement("div"); map._applyTerrainTexture(forest, { terrainType: "Forest", col: 0 }, local); this.assertTrue(forest.style.backgroundImage.includes("textures/forest.png"), "forest overlays"); this.assertTrue( forest.style.backgroundImage.indexOf("forest.png") < forest.style.backgroundImage.indexOf("grass.png"), "the forest overlay is the top layer" ); const desert = env.window.document.createElement("div"); map._applyTerrainTexture(desert, { terrainType: "Desert", col: 4 }, local); this.assertTrue(desert.style.backgroundImage.includes("textures/sand.png"), "desert uses sand"); this.assertFalse(desert.style.backgroundImage.includes("grass.png"), "desert has no grass"); const mountain = env.window.document.createElement("div"); map._applyTerrainTexture(mountain, { terrainType: "Mountain", col: 6 }, local); this.assertTrue( mountain.style.backgroundImage.includes("textures/mountain.png"), "mountain overlays" ); this.assertTrue( mountain.style.backgroundImage.includes("textures/grass.png"), "mountain sits on grass" ); // Hills have no overlay any more: they are grass, desaturated by height // in the shader. const hills = env.window.document.createElement("div"); map._applyTerrainTexture(hills, { terrainType: "Hills", col: 1 }, local); this.assertTrue(hills.style.backgroundImage.includes("textures/grass.png"), "hills use grass"); this.assertFalse(hills.style.backgroundImage.includes("hills.png"), "hills have no overlay"); } finally { teardownDom(env); } } async test_renders_the_pre_generated_road_network() { 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"); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capitalKey = `${capital.coords.x},${capital.coords.y}`; this.assertTrue(map.railways.has(capitalKey), "a city carries a railway"); this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks"); } finally { teardownDom(env); } } async test_renders_railways_as_texture_quads() { const state = smallState(); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capitalKey = `${capital.coords.x},${capital.coords.y}`; const neighbour = state.topology.neighbours(capital.coords.x, capital.coords.y)[0]; state.railways.add(capitalKey); state.railways.add(`${neighbour.x},${neighbour.y}`); state._improvementVersion += 1; const { env, map, snapshot } = await this.build(state); try { this.assertSize(map.railways, snapshot.railways.length, "the snapshot's railways are mirrored"); this.assertTrue(map.railways.has(capitalKey), "the capital carries a railway"); const segs = env.$("#layer-roads .rail-seg"); this.assertGreater(segs.length, 0, "railways are drawn as quads"); this.assertTrue((segs.first().attr("style") || "").includes("rail.png"), "the rail texture is used"); } finally { teardownDom(env); } } async test_unit_views_show_their_owners_flag() { const state = smallState(); const { env, map } = await this.build(state); try { const flags = env.$("#layer-entities .unit .unit-flag"); this.assertSize(flags, state.units.length, "every unit carries a flag"); const unit = state.units[0]; const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id); const view = map._unitViews.get(unit.id); const src = (view && view.querySelector(".unit-flag").getAttribute("src")) || ""; this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`); } finally { teardownDom(env); } } async test_predict_move_starts_a_local_route() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); const path = await map.predictMove(unit, goal); this.assertNotNull(path, "a route was predicted"); this.assertGreaterOrEqual(path.length, 2); const motion = map._unitMotion.get(unit.id); this.assertTrue(motion.predicting, "the route is marked as a prediction"); this.assertSize(motion.segmentHours, path.length - 1); map.animate(0.25); this.assertGreater(motion.pathHours, 0, "the predicted route advances"); } finally { teardownDom(env); } } async test_plan_move_computes_a_route_without_predicting() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); const before = map._unitMotion.get(unit.id); const route = await map.planMove(unit, goal); this.assertNotNull(route, "a route was planned"); this.assertGreaterOrEqual(route.length, 2); this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction"); this.assertFalse(map._unitMotion.get(unit.id).predicting); const applied = await map.predictMove(unit, goal); this.assertEqual(applied, route, "predicting follows the planned route"); this.assertTrue(map._unitMotion.get(unit.id).predicting); } finally { teardownDom(env); } } async test_an_aircraft_is_predicted_along_the_server_flight_line() { const state = smallState(); const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); const cities = state.cities.filter((c) => c.civ === 0); for (const city of cities) city.buildings[airport] = 1; const [home, away] = cities; const jet = state.protoUnits.find((p) => p.id === "jet_fighter"); const unit = state._spawnUnit(home.coords, 0, jet, home.id); const { env, map, snapshot } = await this.build(state); try { const data = snapshot.units.find((u) => u.id === unit.id); const route = await map.planMove(data, away.coords); this.assertNotNull(route, "a flight was planned"); this.assertEqual(route.length, 2, "the flight is a straight two-point line"); // The browser's flight time matches the server's for the same leg. const clientHours = map._flightHours(jet, route[0], route[1], null); const serverHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords)); this.assertApprox(clientHours, serverHours, 1e-9, "browser and server agree on flight time"); await map.predictMove(data, away.coords, null, route); const motion = map._unitMotion.get(unit.id); this.assertTrue(motion.flight, "the motion is flagged as a flight"); this.assertEqual(motion.path.length, 2); } finally { teardownDom(env); } } async test_plan_move_reuses_a_shared_route() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); const cache = new Map(); let searches = 0; const original = map._search.bind(map); map._search = (...args) => { searches += 1; return original(...args); }; const first = await map.planMove(unit, goal, null, cache); const second = await map.planMove(unit, goal, null, cache); this.assertEqual(searches, 1, "the second plan reuses the cached search"); this.assertTrue(first === second, "the same route is handed back"); } finally { teardownDom(env); } } async test_syncs_and_draws_an_authoritative_path() { const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); state.requestMove(unit.id, goal); const { env, map, snapshot } = await this.build(state); try { map.setSelectedUnit(unit.id); map.animate(0.5); this.assertGreater(map._unitMotion.get(unit.id).pathHours, 0, "local time advances"); this.assertGreater(env.$("#layer-paths .border-seg").length, 0, "the route is drawn"); // Re-syncing the same path keeps the local clock (no visible yank). map.applySnapshot(snapshot); this.assertGreaterOrEqual(map._unitMotion.get(unit.id).path.length, 2); // Predicting from a unit that already walks a path reads its position. await map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit)); this.assertTrue(map._unitMotion.get(unit.id).predicting); } finally { teardownDom(env); } } async test_an_air_strike_line_is_straight_not_an_arc() { const state = smallState(); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const jet = state._spawnUnit(spot, 0, state.protoUnits.find((p) => p.id === "jet_fighter")); const goal = state.topology .neighbours(spot.x, spot.y) .find((n) => state._isLand(n) && !state.unitAt(n)); jet.strikeTarget = { x: goal.x, y: goal.y }; const { env, map } = await this.build(state); try { map.setSelectedUnit(jet.id); map.animate(0); const d = env.$("#layer-strike-arcs .target-line").get(0).getAttribute("d"); this.assertTrue(d.includes(" L "), "the air strike is a straight segment"); this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft"); this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell still travels the line"); this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed"); } finally { teardownDom(env); } } async test_draws_a_bombardment_line_and_ring_always_visible() { const state = smallState(); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const artillery = state._spawnUnit( spot, 0, state.protoUnits.find((p) => p.id === "artillery") ); const goal = state.topology .neighbours(spot.x, spot.y) .find((n) => state._isLand(n) && !state.unitAt(n)); state._spawnUnit(goal, 1, state.protoUnits[0]); this.assertTrue(state.requestBombard([artillery.id], goal), "the battery opens fire"); const { env, map } = await this.build(state); try { map.setSelectedUnit(artillery.id); map.animate(0); const path = env.$("#layer-strike-arcs .target-line").get(0); const d = path.getAttribute("d"); this.assertTrue(d.includes("Q"), "the barrage is drawn as a curve, not a straight line"); // The control point is lifted above both ends, so the shell arcs upwards. const startY = Number(d.split(" ")[2]); const controlY = Number(d.split(" ")[5]); this.assertLess(controlY, startY, "the arc rises above the battery"); this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell travels the arc"); this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed"); this.assertSize( env.$("#layer-strike-arcs .target-selected"), 1, "a selected barrage stands out" ); this.assertSize( env.$("#layer-targets .target-selected"), 1, "the selected target ring stands out" ); // A standing barrage stays visible even when nothing is selected. map.clearSelection(); map.animate(0); this.assertSize(env.$("#layer-strike-arcs .target-line"), 1, "the line stays drawn"); this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn"); this.assertSize( env.$("#layer-strike-arcs .target-selected"), 0, "nothing is highlighted without a selection" ); } finally { teardownDom(env); } } async test_a_stacked_bombardment_draws_one_arc_and_ring() { const state = smallState(); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const artilleryProto = state.protoUnits.find((p) => p.id === "artillery"); const first = state._spawnUnit(spot, 0, artilleryProto); const second = state._spawnUnit(spot, 0, artilleryProto); const goal = state.topology .neighbours(spot.x, spot.y) .find((n) => state._isLand(n) && !state.unitAt(n)); state._spawnUnit(goal, 1, state.protoUnits[0]); this.assertTrue( state.requestBombard([first.id, second.id], goal), "the whole stack opens fire" ); const { env, map } = await this.build(state); try { map.setSelectedUnit(first.id); map.animate(0); this.assertSize( env.$("#layer-strike-arcs .target-line"), 1, "a stacked barrage draws a single arc" ); this.assertSize( env.$("#layer-targets .target-ring"), 1, "a stacked barrage rings the target once" ); // Selecting the other battery keeps the one shared arc highlighted. map.setSelectedUnit(second.id); map.animate(0); this.assertSize( env.$("#layer-strike-arcs .target-selected"), 1, "the shared arc stays highlighted" ); } finally { teardownDom(env); } } async test_city_name_carries_a_production_progress_bar() { const state = smallState(); state.budgets.set(0, 1.0e15); const city = state.cities.find((c) => c.civ === 0); grantBuilding(state, city, "barracks"); state.requestTrain(city.id, 0); state.training.get(city.id)[0].elapsedHours = state.training.get(city.id)[0].totalHours / 4; const { env, map } = await this.build(state); try { const bar = map._cityViews.get(city.id).querySelector(".city-progress"); this.assertNotNull(bar, "the city has a progress bar"); this.assertNotEqual(bar.style.display, "none", "the bar is shown while producing"); this.assertTrue(parseFloat(bar.querySelector(".fill").style.width) > 0, "it is filled"); this.assertFalse(bar.classList.contains("building"), "a unit queues a blue bar"); } finally { teardownDom(env); } } async test_foreign_city_hides_its_production() { const state = smallState(); state.budgets.set(1, 1.0e15); const enemy = state.cities.find((c) => c.civ === 1); grantBuilding(state, enemy, "barracks"); this.assertTrue(state.requestTrain(enemy.id, 0)); state.training.get(enemy.id)[0].elapsedHours = 1; const { env, map } = await this.build(state); try { const bar = map._cityViews.get(enemy.id).querySelector(".city-progress"); this.assertNotNull(bar, "the enemy city has a progress element"); this.assertEqual(bar.style.display, "none", "a foreign city never shows production"); } finally { teardownDom(env); } } async test_each_building_level_adds_a_square_around_the_city() { const state = smallState(); stripPorts(state, 0); const city = state.cities.find((c) => c.civ === 0); city.buildings[0] = 2; city.buildings[1] = 1; const { env, map } = await this.build(state); try { const squares = map._cityViews.get(city.id).querySelectorAll(".city-building-square"); this.assertEqual(squares.length, 3, "one square per building level"); // The first square's angle and radius come straight from the seed hash. const angle = map._cityHash(city.id, 0, 0) * Math.PI * 2; const radius = 0.55 + map._cityHash(city.id, 0, 1) * 0.4; this.assertApprox( parseFloat(squares[0].style.left), 50 + Math.cos(angle) * radius * 100, 1e-6, "the scatter is a deterministic function of the map seed" ); } finally { teardownDom(env); } } async test_snapshot_truncation_uses_one_tile_index() { const state = smallState(); const unit = state.units.find((u) => u.civ === 0); state.requestMove(unit.id, adjacentLand(state, unit)); const { env, map } = await this.build(state); try { let scans = 0; map._unitsAtCoords = () => { scans += 1; return []; }; map.applySnapshot(state.snapshot(0)); this.assertEqual(scans, 0, "route truncation uses the snapshot's tile index"); } finally { teardownDom(env); } } async test_strike_overlay_never_rescans_the_army_per_strike() { const state = smallState(); const artilleryProto = state.protoUnits.find((p) => p.id === "artillery"); for (let i = 0; i < 6; i++) { const spot = state.landCells.find( (c) => !state.unitAt(c) && !state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n)) ); const battery = state._spawnUnit(spot, 0, artilleryProto); const goal = state.topology .neighbours(spot.x, spot.y) .find((n) => state._isLand(n) && !state.unitAt(n)); state._spawnUnit(goal, 1, state.protoUnits[0]); this.assertTrue(state.requestBombard([battery.id], goal), "the battery opens fire"); } const { env, map } = await this.build(state); try { // The old overlay called `unitIdsAt` once per barrage, and that scanned // every unit; the per-snapshot tile index replaces both scans. let idScans = 0; map.unitIdsAt = () => { idScans += 1; return []; }; let occupantScans = 0; map._unitsAtCoords = () => { occupantScans += 1; return []; }; map._drawStrikeTargets(true); this.assertGreater(env.$("#layer-strike-arcs .target-line").length, 1, "the barrages are drawn"); this.assertEqual(idScans, 0, "no per-strike army scan"); this.assertEqual(occupantScans, 0, "no per-strike occupant scan"); // A redraw reuses the existing nodes instead of rebuilding them. const firstPath = env.$("#layer-strike-arcs .target-line").get(0); map._drawStrikeTargets(true); this.assertEqual( env.$("#layer-strike-arcs .target-line").get(0), firstPath, "the arc nodes are reused across frames" ); } finally { teardownDom(env); } } }