Files
Battle-for-Tismo/tests/map_view_test.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

1001 lines
39 KiB
JavaScript

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, grantBuilding, stripPorts } 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;
const snapshot = state.snapshot(0);
// The snapshot ships the nations in their seeded order; rebuild the full
// catalogue in that same order so owner indices line up, exactly as the
// game screen does.
const catalogue = snapshot.civs.map(
(c) => CIVILISATIONS.find((full) => full.id === c.id) || c
);
map.setCatalogue(catalogue, PROTO_UNITS);
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, 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_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_in_view_follows_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const coords = { x: capital.coords.x, y: capital.coords.y };
map.centerOn(coords);
this.assertTrue(map.tileInView(coords), "the centred tile is in view");
this.assertTrue(map.tileInView(coords, 1), "still in view with a margin");
// Pan away along the (unwrapped) north-south axis: the tile drops out.
map.camera.y += 100000;
this.assertFalse(map.tileInView(coords), "a far tile is out of view");
} 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);
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_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);
}
}
}