The catalogue had grown a barracks, wartime shelters, an air-defense battery, a warm-water port and a shipyard, but only as data: infantry still trained without a barracks, shelters did nothing, and an airport was just an airbase. Each building now names the rule it grants with a `mechanic` id, so the simulation and the city panel describe the same thing without magic strings. - Barracks and shipyards open one more production line each. A line sticks to one entry at full rate, so two units build at once rather than the head absorbing every line; the base behaviour (one queue, overflow carrying into the next entry) is unchanged. - Wartime shelters keep 10% of civilians per level safe from bombardment and are very hard to knock down, and ranged fire can now destroy a building level outright, with no refund. Each building rolls its own chance per point of damage, from a generator seeded off the world so a bombardment replays identically. - Air defense fires one volley at a hostile aircraft the moment it enters the city tile, scaled by its level; a plane shot down never drops its bombs. - A port adds 1000 GDP per capita per level, per adjacent sea tile, to every tile in its region. - An airport draws a daily trickle of people from every other airport city in the world, scaled by the destination's level. - Wired the new effects into the city panel and taught the repeatable Industrial Automation to satisfy other technologies' prerequisites. Added tests for each mechanic and for the parallel lines.
958 lines
37 KiB
JavaScript
958 lines
37 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 } 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() {
|
|
// 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 } = 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 = 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_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_each_building_level_adds_a_square_around_the_city() {
|
|
const state = smallState();
|
|
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);
|
|
}
|
|
}
|
|
}
|