Files
Battle-for-Tismo/tests/map_view_test.js
T
adrien 3acd2487df Added roads, regional taxation and a reworked budget view
Roads are pre-generated between cities with a greedy geometric spanner and flatten unit movement cost. City improvements now only affect the tiles their region controls, with per-region tax collection and a Cities tab. The budget is a grouped, scrollable table with 25%/year tax and a 10%-of-GDP opening treasury. Also merged the tile info into the city panel, kept conflict and budget expansion state across snapshots, added join notifications, filled border corners, drew battle sides as stacks and made tab overflow scroll.
2026-09-18 07:11:59 +02:00

596 lines
22 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 {
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_renders_the_pre_generated_road_network() {
const state = defaultState();
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.roads.has(capitalKey), "a city carries a road");
this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks");
} 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);
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 = defaultState();
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 = defaultState();
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 = defaultState();
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 = 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.assertFalse(map.lod, "detailed at close zoom");
this.assertSize(env.$("#layer-overview .overview-block"), 0, "no overview until it is needed");
map.camera.zoom = 0.01;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
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.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 {
// 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 = 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 = 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 = 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 = 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 = 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 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 = 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.
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_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 = (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_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);
}
}
async test_stacked_units_show_one_icon_with_a_count_badge() {
const state = defaultState();
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 = defaultState();
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 = defaultState();
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 = defaultState();
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 = defaultState();
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 = defaultState();
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);
}
}
}