465 lines
18 KiB
JavaScript
465 lines
18 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 { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js";
|
|
import { smallState, adjacentLand, seaTile } from "./framework/helpers.js";
|
|
import { TREATY_ALLIANCE } from "../shared/data.js";
|
|
|
|
export class MapViewInputTest extends MapViewFixture {
|
|
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_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_shift_click_toggles_one_unit_instead_of_the_whole_stack() {
|
|
const state = smallState();
|
|
const { env, map, snapshot } = await this.build(state);
|
|
try {
|
|
const unit = snapshot.units.find((u) => u.civ === 0);
|
|
let selected = -1;
|
|
let toggled = -1;
|
|
map.onUnitSelected = (id) => { selected = id; };
|
|
map.onUnitToggled = (id) => { toggled = id; };
|
|
const coords = { x: unit.coords[0], y: unit.coords[1] };
|
|
map._onLeftClick(coords, { shiftKey: true });
|
|
this.assertEqual(toggled, unit.id, "shift-click asks to toggle that one unit");
|
|
this.assertEqual(selected, -1, "and does not grab the whole stack");
|
|
map._onLeftClick(coords, {});
|
|
this.assertEqual(selected, unit.id, "a plain click still takes the stack");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_prediction_crosses_allied_and_at_war_land_but_not_a_neutral() {
|
|
const state = smallState(["france", "britain", "poland"]);
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
const proto = map.protoUnits[0];
|
|
const occupied = (c) =>
|
|
Array.from(map._unitData.values()).some((u) => u.coords[0] === c.x && u.coords[1] === c.y);
|
|
const tile = Object.keys(map.tiles)
|
|
.map((k) => parseKey(k))
|
|
.find(
|
|
(c) =>
|
|
map.tiles[`${c.x},${c.y}`].terrainClass === "Land" &&
|
|
!map._cityCiv.has(`${c.x},${c.y}`) &&
|
|
!occupied(c)
|
|
);
|
|
this.assertNotNull(tile, "the fixture has an empty land tile");
|
|
map.territory.set(`${tile.x},${tile.y}`, 2);
|
|
this.assertFalse(map._canEnter(0, proto, tile), "a neutral third party's land is a wall");
|
|
|
|
state.requestDeclareWar(0, 2);
|
|
map.applySnapshot(state.snapshot(0));
|
|
this.assertTrue(map._isAtWar(0, 2), "the war is mirrored for prediction");
|
|
this.assertTrue(map._canEnter(0, proto, tile), "a nation at war may be crossed");
|
|
|
|
const allied = smallState(["france", "britain", "poland"]);
|
|
const { env: env2, map: map2 } = await this.build(allied);
|
|
try {
|
|
allied.requestProposeTreaty(0, 2, TREATY_ALLIANCE);
|
|
allied.requestRespondTreaty(2, allied.treatyProposals[0].id, true);
|
|
map2.applySnapshot(allied.snapshot(0));
|
|
map2.territory.set(`${tile.x},${tile.y}`, 2);
|
|
this.assertTrue(map2._isAllied(0, 2), "the alliance is mirrored for prediction");
|
|
this.assertTrue(map2._canEnter(0, proto, tile), "an ally's land may be crossed");
|
|
} finally {
|
|
teardownDom(env2);
|
|
}
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_shift_mousedown_on_a_unit_icon_detaches_that_unit() {
|
|
const state = smallState();
|
|
const { env, map, snapshot } = await this.build(state);
|
|
try {
|
|
const unit = snapshot.units.find((u) => u.civ === 0);
|
|
let toggled = -1;
|
|
let selected = -1;
|
|
map.onUnitToggled = (id) => { toggled = id; };
|
|
map.onUnitSelected = (id) => { selected = id; };
|
|
const view = map._unitViews.get(unit.id);
|
|
this.assertNotNull(view, "the unit has a view");
|
|
view.dispatchEvent(
|
|
new env.window.MouseEvent("mousedown", { button: 0, shiftKey: true, bubbles: true })
|
|
);
|
|
this.assertEqual(toggled, unit.id, "shift-mousedown detaches that one unit");
|
|
this.assertEqual(selected, -1, "it does not grab the whole stack");
|
|
view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true }));
|
|
this.assertEqual(selected, unit.id, "a plain mousedown still takes the stack");
|
|
} 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_is_a_plain_move() {
|
|
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 move = null;
|
|
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
|
|
map.setSelectedUnit(unit.id);
|
|
map._onRightClick(goal, {});
|
|
this.assertNotNull(move, "a right-click is a move, never a ranged attack");
|
|
this.assertEqual(move.coords, goal);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_left_click_in_target_mode_chooses_the_target() {
|
|
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 chosen = null;
|
|
let selected = null;
|
|
map.onTargetChosen = (coords) => { chosen = coords; };
|
|
map.onTargetCancelled = () => map.clearTargeting();
|
|
map.onUnitSelected = () => { selected = true; };
|
|
map.beginTargeting({
|
|
unitIds: [unit.id],
|
|
origin: { x: unit.coords.x, y: unit.coords.y },
|
|
range: 3,
|
|
});
|
|
map._onLeftClick(goal);
|
|
this.assertEqual(chosen, goal, "the left-click picks the target tile");
|
|
this.assertNull(selected, "the click does not also select a unit");
|
|
this.assertNotNull(map._targeting, "target mode stays on until the controller clears it");
|
|
map._onRightClick(goal, {});
|
|
this.assertNull(map._targeting, "a right-click cancels target mode");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_a_unit_icon_does_not_steal_a_target_click() {
|
|
const state = smallState();
|
|
const attacker = state.units.find((u) => u.civ === 0);
|
|
const goal = adjacentLand(state, attacker);
|
|
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
|
|
const { env, map } = await this.build(state);
|
|
try {
|
|
let selected = -1;
|
|
let reachedMap = false;
|
|
map.onUnitSelected = (id) => { selected = id; };
|
|
map.$viewport.on("mousedown", () => { reachedMap = true; });
|
|
map.beginTargeting({ unitIds: [attacker.id], origin: attacker.coords, range: 3 });
|
|
const view = map._unitViews.get(defender.id);
|
|
this.assertNotNull(view, "the defender has a view");
|
|
// Mousedown on the enemy icon while picking a target must fall through to
|
|
// the map, not select the enemy (which would clear the attacker).
|
|
view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true }));
|
|
this.assertEqual(selected, -1, "the target icon does not steal the click");
|
|
this.assertTrue(reachedMap, "the click reaches the map so the target is chosen");
|
|
} 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_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);
|
|
}
|
|
}
|
|
}
|