import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { MapView, inwardOffset } from "../client/js/map_view.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, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js"; export class MapViewTest extends TestCase { async build(state) { const env = await setupDom(); const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$)); map.$viewport.width = () => 800; map.$viewport.height = () => 600; map.setCatalogue(CIVILISATIONS, PROTO_UNITS); const snapshot = state.snapshot(0); map.applySnapshot(snapshot); return { env, map, snapshot }; } 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_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 } = 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 src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || ""; this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`); } finally { teardownDom(env); } } async test_centers_on_the_capital() { const state = smallState(); const { env, map } = await this.build(state); try { const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const local = mapToLocal(capital.coords.x, capital.coords.y); this.assertApprox(map.camera.x, local.x, 1e-6); this.assertApprox(map.camera.y, local.y, 1e-6); this.assertTrue(env.$("#map-world").css("transform").includes("scale(1)")); } finally { teardownDom(env); } } async test_tile_at_screen_round_trips() { const state = smallState(); const { env, map } = await this.build(state); try { const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const local = mapToLocal(capital.coords.x, capital.coords.y); const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom; const screenY = 300 + (local.y - map.camera.y) * map.camera.zoom * CAMERA_TILT; this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords); } finally { teardownDom(env); } } async test_tile_at_screen_accounts_for_the_height_lift() { const state = smallState(); const { env, map } = await this.build(state); try { const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const local = mapToLocal(capital.coords.x, capital.coords.y); const height = 0.5; map.glRenderer = { pointHeight: () => height }; const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom; // Where the raised tile centre is actually drawn. const screenY = 300 + ((local.y - map.camera.y) * CAMERA_TILT - height * HEIGHT_LIFT) * map.camera.zoom; this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords, "the raised tile is selected where it is drawn"); } finally { teardownDom(env); } } async test_surface_inversion_picks_the_frontmost_crossing() { const state = smallState(); const { env, map } = await this.build(state); try { // A plateau 10..30px south of the flat estimate. The pixel shows both the // flat ground at y=90 and the raised plateau at y = 90 + 0.6*HEIGHT_LIFT/ // tilt; the plateau is drawn last, so it must win. map.glRenderer = { pointHeight: (x, y) => (y >= 100 && y <= 130 ? 0.6 : 0), }; const expected = 90 + 0.6 * (HEIGHT_LIFT / CAMERA_TILT); this.assertApprox(map._surfaceY(0, 90), expected, 0.5, "the frontmost surface wins"); } finally { teardownDom(env); } } async test_outline_runs_split_solid_and_dotted() { const state = smallState(); const { env, map } = await this.build(state); try { const coords = [ { x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, ]; const hidden = [false, true, true, false]; this.assertEqual(map._outlineRuns(coords, hidden, false), "M0.00 10.00L0.00 0.00", "the visible segment is its own run"); this.assertEqual(map._outlineRuns(coords, hidden, true), "M0.00 0.00L10.00 0.00L10.00 10.00L0.00 10.00", "the hidden segments chain together"); } finally { teardownDom(env); } } async test_wrapped_tile_lookup_stays_in_range() { const state = smallState(); const { env, map } = await this.build(state); try { const coords = map.tileAtScreen(100000, 300); const minX = map.topology.originX; const maxX = map.topology.originX + map.topology.width - 1; this.assert(coords.x >= minX && coords.x <= maxX, `x ${coords.x} wrapped`); } finally { teardownDom(env); } } async test_terrain_wraps_across_the_seam() { const state = smallState(); const { env, map } = await this.build(state); try { this.assertGreater(map._period, 0, "the map has a wrap period"); const rec = map._chunks.values().next().value; this.assertNotNull(rec, "a chunk is materialised"); const chunkKey = rec.cx + "," + rec.cy; const beforeShift = rec.shift; // Scrolling exactly one period must land on the same picture: every chunk // advances to its next wrapped copy, cancelling the camera movement. map.panBy(map._period, 0); const after = map._chunks.get(chunkKey); this.assertNotNull(after, "the same chunk stays materialised one period over"); this.assertApprox(after.shift, beforeShift + map._period, 1e-6); this.assertTrue( after.terrainEl.style.transform.includes(`translateX(${after.x + after.shift}px)`), `expected translateX(${after.x + after.shift}px), got ${after.terrainEl.style.transform}` ); } finally { teardownDom(env); } } async test_chunks_meet_seamlessly_at_the_wrap() { 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 // and the wrapped copy of the west-edge chunk are on screen together. const t = map.topology; map.camera.x = (t.originX + t.width) * COL_STEP; map.camera.y = 0; map._applyCamera(); const rows = new Map(); for (const rec of map._chunks.values()) { if (!rows.has(rec.cy)) rows.set(rec.cy, []); rows.get(rec.cy).push(rec); } let row = null; for (const list of rows.values()) { if (!row || list.length > row.length) row = list; } this.assertGreater(row.length, 1, "several chunks share the seam row"); row.sort((a, b) => (a.x + a.shift) - (b.x + b.shift)); for (let i = 1; i < row.length; i++) { const previousRight = row[i - 1].x + row[i - 1].shift + row[i - 1].px; const currentLeft = row[i].x + row[i].shift; this.assertApprox(currentLeft - previousRight, COL_STEP, 1e-6); } } finally { teardownDom(env); } } async test_dragging_wraps_with_transforms_not_layout() { const state = smallState(); const { env, map } = await this.build(state); try { const rec = map._chunks.values().next().value; const beforeLeft = rec.terrainEl.style.left; // The drag path updates the camera but defers the chunk sync to the render // loop; either way the chunk must be moved with a transform, not layout. map.camera.x += map._period; map._applyCamera(false); map.animate(0); this.assertEqual(rec.terrainEl.style.left, beforeLeft, "left is never rewritten"); this.assertTrue(rec.terrainEl.style.transform.includes("translateX")); } finally { teardownDom(env); } } async test_wrap_shift_picks_the_copy_nearest_the_camera() { const state = smallState(); const { env, map } = await this.build(state); try { const period = map._period; map.camera.x = period; this.assertEqual(map._wrapShift(0), period); this.assertEqual(map._wrapShift(period), 0); // A point already beside the camera is never shifted. this.assertEqual(map._wrapShift(period - 10), 0); } finally { teardownDom(env); } } async test_far_zoom_keeps_the_detailed_terrain() { const state = smallState(); const { env, map } = await this.build(state); try { map.camera.zoom = 0.01; map._applyCamera(); this.assertFalse(env.$("#map-world").hasClass("lod")); this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer")); this.assertSize(env.$("#layer-overview"), 0, "there is no simplified overview"); } finally { teardownDom(env); } } async test_border_offset_points_towards_the_owner() { const a = { x: 0, y: 0 }; const b = { x: 0, y: 10 }; this.assertEqual(inwardOffset(a, b, { x: -5, y: 5 }, 3), 3); this.assertEqual(inwardOffset(a, b, { x: 5, y: 5 }, 3), -3); } async test_neighbouring_borders_are_inset_not_overlaid() { const state = smallState(); const { env, map } = await this.build(state); try { // Bring the two-tile territory into view so its chunk is materialised. map.camera.x = 0; map.camera.y = 0; map._applyCamera(); map.civilisations = [{ primaryColour: "#111111" }, { primaryColour: "#eeeeee" }]; map.territory = new Map([["0,0", 0], ["1,0", 1]]); map._renderBorders(); // Each segment is nudged towards its owner's tile, so the two sides of a // shared edge sit next to each other instead of painting over each other. const segments = env.$("#layer-borders .border-seg").toArray(); this.assertGreater(segments.length, 0); for (const segment of segments) { this.assert( segment.style.transform.includes("translateY"), `expected an inset border, got ${segment.style.transform}` ); } } finally { teardownDom(env); } } async test_selecting_a_unit_marks_its_view() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); map.setSelectedUnit(unit.id); this.assertSize(env.$("#layer-entities .unit.selected"), 1); this.assertEqual(map.unitAt({ x: unit.coords[0], y: unit.coords[1] }), unit.id); } 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_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_pan_moves_the_camera() { const state = smallState(); const { env, map } = await this.build(state); try { const beforeX = map.camera.x; const beforeY = map.camera.y; map.panBy(10, -5); this.assertApprox(map.camera.x, beforeX + 10, 1e-9); this.assertApprox(map.camera.y, beforeY - 5, 1e-9); } 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_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-targets .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-targets .target-shell"), 1, "a shell travels the arc"); this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed"); this.assertSize( env.$("#layer-targets .target-selected"), 2, "a selected barrage stands out" ); // A standing barrage stays visible even when nothing is selected. map.clearSelection(); map.animate(0); this.assertSize(env.$("#layer-targets .target-line"), 1, "the line stays drawn"); this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn"); this.assertSize( env.$("#layer-targets .target-selected"), 0, "nothing is highlighted without a selection" ); } finally { teardownDom(env); } } async test_click_handling_selects_and_orders() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); let selected = -1; map.onUnitSelected = (id) => { selected = id; }; map._onLeftClick({ x: unit.coords[0], y: unit.coords[1] }); this.assertEqual(selected, unit.id); let order = null; map.onMoveOrdered = (ids, coords) => { order = { ids, coords }; }; map.setSelectedUnit(unit.id); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); map._onRightClick(goal); this.assertEqual(order.ids, [unit.id]); this.assertEqual(order.coords, goal); let tile = null; map.onTileRequested = (coords) => { tile = coords; }; map.clearSelection(); map._onRightClick(goal); this.assertEqual(tile, goal); } finally { teardownDom(env); } } async test_right_click_on_a_unit_icon_orders_the_selected_stack() { 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]); const { env, map } = await this.build(state); try { let moved = null; map.onMoveOrdered = (ids, coords) => { moved = { ids, coords }; }; map.setSelectedUnit(unit.id); const local = mapToLocal(goal.x, goal.y); const clientX = 400 + (local.x - map.camera.x) * map.camera.zoom; const clientY = 300 + (local.y - map.camera.y) * map.camera.zoom * CAMERA_TILT; const view = map._unitViews.get(target.id); this.assertNotNull(view, "the target unit has a view"); // Real bubbling events, so the icon's own native handler is exercised: // it must let right-clicks reach the viewport's mousedown listener. const press = new env.window.MouseEvent("mousedown", { button: 2, bubbles: true, clientX, clientY }); view.dispatchEvent(press); const release = new env.window.MouseEvent("mouseup", { button: 2, bubbles: true, clientX, clientY }); env.window.dispatchEvent(release); this.assertNotNull(moved, "right-clicking an icon gives the order"); this.assertEqual(moved.ids, [unit.id]); this.assertEqual(moved.coords, goal); } finally { teardownDom(env); } } async test_left_click_selects_city_or_clears() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { let chosen = null; map.onCitySelected = (id) => { chosen = id; }; // A non-capital city tile has no unit on it. const city = snapshot.cities.find((c) => c.civ === 0 && !c.isCapital); const coords = { x: city.coords[0], y: city.coords[1] }; map._onLeftClick(coords); this.assertEqual(chosen, city.id); this.assertEqual(map.cityAt(coords).id, city.id); let cleared = -1; map.onUnitSelected = (id) => { cleared = id; }; map.setSelectedUnit(snapshot.units[0].id); const sea = seaTile(state); map._onLeftClick(sea); this.assertEqual(cleared, 0, "clicking empty ground clears the selection"); this.assertEqual(map.unitAt(sea), 0); this.assertNull(map.cityAt(sea)); } finally { teardownDom(env); } } async test_city_marker_click_reports_the_city() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { let chosen = null; map.onCitySelected = (id) => { chosen = id; }; const owned = env.$("#layer-entities .city[data-owned='1']"); this.assertGreater(owned.length, 0); owned.first().find(".marker").click(); this.assertEqual(chosen, snapshot.cities.find((c) => c.civ === 0).id); } finally { teardownDom(env); } } async test_clear_selection_notifies() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); map.setSelectedUnit(unit.id); let selected = -1; map.onUnitSelected = (id) => { selected = id; }; map.clearSelection(); this.assertEqual(selected, 0); this.assertSize(env.$("#layer-entities .unit.selected"), 0); } finally { teardownDom(env); } } async test_selected_tile_is_outlined() { const state = smallState(); const { env, map } = await this.build(state); try { map.setSelectedTile({ x: 0, y: 0 }); const hex = env.$("#layer-highlight .tile-selection"); this.assertSize(hex, 1, "the outline is created"); this.assertTrue(hex.hasClass("active"), "the outline is shown"); const points = (hex.find("polygon").attr("points") || "").split(" "); this.assertSize(points, 6, "a hexagon outline"); const wrapped = map._wrappedPosition(mapToLocal(0, 0)); this.assertApprox(parseFloat(hex[0].style.left), wrapped.x - HEX_W / 2, 1e-6); map.clearSelectedTile(); this.assertFalse(hex.hasClass("active"), "the outline is hidden again"); } finally { teardownDom(env); } } async test_selected_tile_reports_its_region() { const state = smallState(); const { env, map } = await this.build(state); try { this.assertGreater(map.regions.size, 0, "regions are mirrored from the snapshot"); const [tileKey, cityId] = map.regions.entries().next().value; map.setSelectedTile(parseKey(tileKey)); this.assertEqual(map._selectedRegion, cityId, "the tile's region is selected as a whole"); map.clearSelectedTile(); this.assertNull(map._selectedRegion, "clearing drops the region"); } finally { teardownDom(env); } } async test_stacked_units_show_one_icon_with_a_count_badge() { 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); try { const first = map._unitViews.get(unit.id); const other = map._unitViews.get(second.id); this.assertEqual(first.querySelector(".unit-count").style.display, "block"); this.assertEqual(first.querySelector(".unit-count").textContent, "2"); this.assertEqual(other.style.visibility, "hidden", "the follower is not drawn"); const ids = map.unitIdsAt({ x: unit.coords.x, y: unit.coords.y }).sort((a, b) => a - b); this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b), "both are selectable"); } finally { teardownDom(env); } } async test_right_click_on_a_friendly_stack_opens_the_menu() { 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); try { let menu = null; map.onStackMenu = (coords, ids, x, y) => { menu = { coords, ids, x, y }; }; map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, { clientX: 11, clientY: 22 }); this.assertNotNull(menu, "the stack menu opens"); this.assertEqual(menu.ids.sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b)); this.assertEqual(menu.x, 11); this.assertEqual(menu.y, 22); } finally { teardownDom(env); } } async test_stack_ids_group_units_sharing_a_tile() { 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); try { const ids = map.stackIds(unit.id).sort((a, b) => a - b); this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b)); } finally { teardownDom(env); } } async test_right_click_on_a_friendly_stack_while_selected_moves_there() { 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); try { let menu = null; let moved = null; map.onStackMenu = () => { menu = true; }; map.onMoveOrdered = (ids, coords) => { moved = { ids, coords }; }; map.setSelectedUnit(second.id); map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, {}); this.assertNull(menu, "the stack menu does not open over a selection"); this.assertNotNull(moved, "the selected stack is ordered onto the friendly tile"); this.assertEqual(moved.ids, [second.id]); this.assertEqual(moved.coords, { x: unit.coords.x, y: unit.coords.y }); } finally { teardownDom(env); } } 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]); const { env, map } = await this.build(state); try { let attack = null; let move = null; map.onAttackOrdered = (ids, coords) => { attack = { ids, coords }; }; map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; }; map.setSelectedUnit(unit.id); map._onRightClick(goal, {}); this.assertNotNull(attack, "the enemy stack is attacked"); this.assertNull(move, "the enemy stack is not treated as a plain move"); this.assertEqual(attack.coords, goal); } finally { teardownDom(env); } } async test_ctrl_right_click_schedules_a_destination() { const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); const { env, map } = await this.build(state); try { let schedule = null; let move = null; map.onScheduleOrdered = (ids, coords) => { schedule = { ids, coords }; }; map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; }; map.setSelectedUnit(unit.id); map._onRightClick(goal, { ctrlKey: true }); this.assertNotNull(schedule, "Ctrl-right-click queues a destination"); this.assertNull(move, "a scheduled click is not a plain move"); this.assertEqual(schedule.coords, goal); } 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); 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_each_building_level_adds_a_square_around_the_city() { const state = smallState(); 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_a_moving_unit_splits_from_its_stack_at_once() { 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); try { const tile = { x: unit.coords[0], y: unit.coords[1] }; map._unitMotion.get(unit.id).path = [tile, { x: tile.x + 1, y: tile.y }]; map._unitMotion.get(unit.id).segmentHours = [1]; map._unitMotion.get(unit.id).pathHours = 0.1; map._assignStackOffsets(); this.assertEqual( map._unitViews.get(unit.id).style.visibility, "", "the moving unit is drawn on its own" ); this.assertEqual( map._unitViews.get(second.id).style.visibility, "", "the unit left behind stays visible" ); this.assertEqual( map._unitViews.get(second.id).querySelector(".unit-count").style.display, "none", "the stack badge is gone the moment one member leaves" ); } 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-targets .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-targets .target-line").get(0); map._drawStrikeTargets(true); this.assertEqual( env.$("#layer-targets .target-line").get(0), firstPath, "the arc nodes are reused across frames" ); } finally { teardownDom(env); } } }