476 lines
20 KiB
JavaScript
476 lines
20 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-targets .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-targets .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-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_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-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);
|
|
}
|
|
}
|
|
}
|