423 lines
15 KiB
JavaScript
423 lines
15 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 } from "../shared/hex.js";
|
|
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
|
import { defaultState, 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() {
|
|
const state = defaultState();
|
|
const { env, map, snapshot } = await this.build(state);
|
|
try {
|
|
this.assertSize(env.$("#layer-terrain .hex"), 64 * 40);
|
|
this.assertSize(map.territory, snapshot.territory.length);
|
|
this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length);
|
|
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
|
|
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_unit_views_show_their_owners_flag() {
|
|
const state = defaultState();
|
|
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 = defaultState();
|
|
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 = defaultState();
|
|
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;
|
|
this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_wrapped_tile_lookup_stays_in_range() {
|
|
const state = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
const coords = map.tileAtScreen(100000, 300);
|
|
this.assert(coords.x >= -32 && coords.x <= 31, `x ${coords.x} wrapped`);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_terrain_wraps_across_the_seam() {
|
|
const state = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
this.assertGreater(map._period, 0, "the map has a wrap period");
|
|
const west = map._terrainWrap.find((item) => item.centerX < -1000);
|
|
this.assertNotNull(west, "found a west-edge tile");
|
|
const beforeShift = west.shift;
|
|
|
|
// Scrolling exactly one period must land on the same picture: every tile
|
|
// advances to its next wrapped copy, cancelling the camera movement.
|
|
map.panBy(map._period, 0);
|
|
this.assertApprox(west.shift, beforeShift + map._period, 1e-6);
|
|
this.assertTrue(
|
|
west.el.style.transform.includes(`translateX(${west.shift}px)`),
|
|
`expected translateX(${west.shift}px), got ${west.el.style.transform}`
|
|
);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_tiles_meet_seamlessly_at_the_wrap() {
|
|
const state = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
const eastX0 = Math.max(...map._terrainWrap.map((item) => item.centerX));
|
|
const westX0 = Math.min(...map._terrainWrap.map((item) => item.centerX));
|
|
const east = map._terrainWrap.find((item) => item.centerX === eastX0);
|
|
const west = map._terrainWrap.find((item) => item.centerX === westX0);
|
|
this.assertNotNull(east, "found an east-edge tile");
|
|
this.assertNotNull(west, "found a west-edge tile");
|
|
|
|
// Sit on the seam so the wrapped west-edge copy lands next to the east
|
|
// edge instead of leaving a gap.
|
|
map.camera.x = east.centerX;
|
|
map._applyCamera();
|
|
const eastX = east.centerX + east.shift;
|
|
const westX = west.centerX + west.shift;
|
|
this.assertApprox(westX - eastX, COL_STEP, 1e-6);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_dragging_wraps_with_transforms_not_layout() {
|
|
const state = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
const west = map._terrainWrap.find((item) => item.centerX < -1000);
|
|
const beforeLeft = west.el.style.left;
|
|
|
|
// The drag path updates the camera but defers per-element wrapping to the
|
|
// render loop; either way the element must not be laid out again.
|
|
map.camera.x += map._period;
|
|
map._applyCamera(false);
|
|
map.animate(0);
|
|
|
|
this.assertEqual(west.el.style.left, beforeLeft, "left is never rewritten");
|
|
this.assertTrue(west.el.style.transform.includes("translateX"));
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_wrap_shift_picks_the_copy_nearest_the_camera() {
|
|
const state = defaultState();
|
|
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_zooming_out_switches_to_the_overview() {
|
|
const state = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
this.assertGreater(
|
|
env.$("#layer-overview .overview-block").length,
|
|
0,
|
|
"overview blocks built"
|
|
);
|
|
this.assertLess(
|
|
env.$("#layer-overview .overview-block").length,
|
|
Object.keys(map.tiles).length,
|
|
"overview is simpler than the full map"
|
|
);
|
|
this.assertFalse(map.lod, "detailed at close zoom");
|
|
|
|
map.camera.zoom = 0.2;
|
|
map._applyCamera();
|
|
this.assertTrue(map.lod, "overview at far zoom");
|
|
this.assertTrue(env.$("#map-world").hasClass("lod"));
|
|
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
|
|
|
|
map.camera.zoom = 1;
|
|
map._applyCamera();
|
|
this.assertFalse(map.lod, "detailed again when zoomed in");
|
|
this.assertFalse(env.$("#map-world").hasClass("lod"));
|
|
} 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 = defaultState();
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
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 = defaultState();
|
|
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 = defaultState();
|
|
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 = 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 = defaultState();
|
|
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 = 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 = 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_pan_moves_the_camera() {
|
|
const state = defaultState();
|
|
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 = defaultState();
|
|
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.
|
|
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_click_handling_selects_and_orders() {
|
|
const state = defaultState();
|
|
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 = (id, coords) => {
|
|
order = { id, 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.id, 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_left_click_selects_city_or_clears() {
|
|
const state = defaultState();
|
|
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 = defaultState();
|
|
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 = defaultState();
|
|
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);
|
|
}
|
|
}
|
|
}
|