Files
Battle-for-Tismo/tests/map_view_test.js

523 lines
22 KiB
JavaScript

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