import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { GameScreen } from "../client/js/game_screen.js"; import { BUILDINGS, CIVILISATIONS } from "../shared/data.js"; import { smallState, adjacentLand } from "./framework/helpers.js"; function stubNetwork() { return { isAdmin: false, localPlayerId: 2, players: { 2: { id: 2, name: "Carol", admin: false } }, orders: [], setup: null, sendOrder(order) { this.orders.push(order); }, sendGameSetup(setup) { this.setup = setup; }, }; } function makeConfig() { return { playerName: "Carol", playerCivId: "france", civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]], }; } function landAtDistance(state, center, distance) { for (const k in state.tiles) { const parts = k.split(",").map(Number); const coords = { x: parts[0], y: parts[1] }; if (state.tiles[k].terrainClass !== "Land") continue; if (state.cityAt(coords) || state.unitAt(coords)) continue; if (state.topology.tileDistance(center, coords) === distance) return coords; } return null; } export class GameScreenTest extends TestCase { async test_renders_snapshot_and_sends_orders() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); this.assertTrue(env.$("#screen-game").hasClass("active")); const state = smallState(); screen.onState(state.snapshot(0)); this.assertEqual(env.$("#civ-name").text(), "France"); this.assertEqual(env.$("#player-name").text(), "Carol"); this.assertTrue(env.$("#stat-pop").text() !== "0"); this.assertTrue(env.$("#stat-date").text().includes("2000")); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); this.assertFalse(env.$("#city-info").hasClass("hidden")); this.assertEqual(env.$("#city-name").text(), capital.name); env.$("#city-manage").click(); this.assertFalse(env.$("#modal-city").hasClass("hidden")); this.assertSize(env.$("#city-train-list button"), 2, "infantry and artillery can be trained"); env.$("#city-train-list button").first().click(); this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 }); const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); await screen.map.onMoveOrdered([unit.id], goal); this.assertEqual(network.orders[1], { type: "move", unit: unit.id, coords: [goal.x, goal.y] }); this.assertTrue(screen.map._unitMotion.get(unit.id).predicting, "the move was predicted"); screen.sendSetup(); this.assertEqual(network.setup.player_civ, 0); this.assertEqual(network.setup.civilisations, ["france", "britain"]); screen.leave(); this.assertFalse(env.$("#screen-game").hasClass("active")); } finally { teardownDom(env); } } async test_nation_panel_opens() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertSize(env.$("#government-list .row-card"), 3); this.assertSize(env.$("#technology-list .row-card"), 5); screen.leave(); } finally { teardownDom(env); } } async test_buildings_and_nation_modals_refresh_on_snapshot() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); this.assertFalse(env.$("#modal-city").hasClass("hidden")); this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length); screen.onState(state.snapshot(0)); this.assertFalse(env.$("#modal-city").hasClass("hidden")); env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); screen.onState(state.snapshot(0)); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); screen.leave(); } finally { teardownDom(env); } } async test_city_budget_tab_shows_the_region_budget() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); env.$("#modal-city .tab[data-tab=budget]").click(); this.assertTrue(env.$("#city-tab-budget").hasClass("active"), "the budget tab opens"); const cityStats = state.snapshot(0).cityStats.find((c) => c.id === capital.id); this.assertNotNull(cityStats.budget, "the city stats carry a region budget"); const text = env.$("#city-budget-list").text(); this.assertTrue(text.includes("Tax income"), "region income is listed"); this.assertTrue(text.includes("TOTAL"), "the region net is totalled"); screen.leave(); } finally { teardownDom(env); } } async test_training_modal_tracks_progress() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); this.assertTrue(state.requestTrain(capital.id, 0)); screen.onState(state.snapshot(0)); this.assertFalse(env.$("#city-train-status").hasClass("hidden")); this.assertTrue(env.$("#city-train-label").text().includes("Training")); state.training.get(capital.id)[0].elapsedHours = 3; screen.onState(state.snapshot(0)); this.assertTrue(env.$("#city-train-label").text().includes("left")); state.training.delete(capital.id); screen.onState(state.snapshot(0)); this.assertEqual(env.$("#city-train-label").text(), "Training complete"); screen.leave(); } finally { teardownDom(env); } } async test_attack_and_scheduled_move_orders_are_serialised() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const raw = state.units.find((u) => u.civ === 0); const unit = screen._unit(raw.id); const goal = adjacentLand(state, raw); await screen._sendMove([unit], goal, null, "attack"); this.assertEqual(network.orders[0], { type: "attack", unit: unit.id, coords: [goal.x, goal.y], }); await screen._sendMove([unit], goal, null, "schedule"); this.assertEqual(network.orders[1], { type: "schedule", unit: unit.id, coords: [goal.x, goal.y], goals: [[goal.x, goal.y]], append: true, }); screen.leave(); } finally { teardownDom(env); } } async test_ranged_attack_is_sent_without_predicting_a_march() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const home = state.units.find((u) => u.civ === 0); const artillery = state._spawnUnit( home.coords, 0, state.protoUnits.find((u) => u.id === "artillery") ); const goal = adjacentLand(state, artillery); const defender = state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); const unit = screen._unit(artillery.id); this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible"); await screen._sendMove([unit], goal, null, "attack"); this.assertEqual(network.orders[0], { type: "attack", unit: unit.id, coords: [goal.x, goal.y], }); this.assertFalse( screen.map._unitMotion.get(unit.id).predicting, "the icon does not march toward a target hit from range" ); screen.leave(); } finally { teardownDom(env); } } async test_air_strike_order_is_sent_and_the_flight_is_predicted() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const home = state.units.find((u) => u.civ === 0); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto); const goal = adjacentLand(state, home); const defender = state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); const unit = screen._unit(jet.id); this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible"); await screen._sendMove([unit], goal, null, "attack"); this.assertEqual(network.orders[0], { type: "attack", unit: unit.id, coords: [goal.x, goal.y], }); this.assertTrue( screen.map._unitMotion.get(unit.id).predicting, "the aircraft flies toward the target" ); screen.leave(); } finally { teardownDom(env); } } async test_rearming_aircraft_is_refused_with_a_message() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const home = state.units.find((u) => u.civ === 0); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto); jet.strikeReadyHour = 100; const goal = adjacentLand(state, home); state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); await screen._orderMove([screen._unit(jet.id)], goal, "attack"); this.assertEqual(network.orders.length, 0, "no order is sent while re-arming"); this.assertEqual(env.$("#confirm-title").text(), "Aircraft re-arming"); screen.leave(); } finally { teardownDom(env); } } async test_unit_panel_shows_an_aircraft_is_rearming() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const home = state.units.find((u) => u.civ === 0); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto); jet.strikeReadyHour = state.totalHours + 10; screen.onState(state.snapshot(0)); screen._selectUnits([jet.id]); this.assertTrue( env.$("#unit-name").text().includes("re-arming"), "the panel shows the re-arming cooldown" ); screen.leave(); } finally { teardownDom(env); } } async test_unaffordable_air_strike_is_refused_with_a_message() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 0); const home = state.units.find((u) => u.civ === 0); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto); const goal = adjacentLand(state, home); state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); await screen._orderMove([screen._unit(jet.id)], goal, "attack"); this.assertEqual(network.orders.length, 0, "no order is sent without the budget"); this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget"); screen.leave(); } finally { teardownDom(env); } } async test_air_strike_beyond_the_round_trip_is_refused() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const city = state.cities.find((c) => c.civ === 0); const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); city.buildings[airport] = 1; const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const saved = jetProto.enduranceHours; // Two hours of fuel is a ten-tile round trip, so a six-tile strike cannot // get home even though it sits well inside the mission radius. jetProto.enduranceHours = 2; try { const jet = state._spawnUnit(city.coords, 0, jetProto, city.id); const goal = landAtDistance(state, city.coords, 6); this.assertNotNull(goal, "the fixture has a tile six tiles away"); screen.onState(state.snapshot(0)); const unit = screen._unit(jet.id); const reason = screen._airStrikeRangeError([unit], goal); this.assertTrue( typeof reason === "string" && reason.length > 0, "the round trip is recognised as out of reach" ); this.assertTrue(reason.includes("6 tiles away"), "the message names the target distance"); this.assertTrue( reason.includes("effective range is 5 tiles"), "the message names the plane's effective range" ); const near = landAtDistance(state, city.coords, 4); this.assertNotNull(near, "the fixture has a tile four tiles away"); this.assertNull(screen._airStrikeRangeError([unit], near), "a nearer target is in reach"); } finally { jetProto.enduranceHours = saved; } screen.leave(); } finally { teardownDom(env); } } async test_air_strike_route_reaches_a_garrisoned_city() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const city = state.cities.find((c) => c.civ === 0); const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); city.buildings[airport] = 1; const bomberProto = state.protoUnits.find((u) => u.id === "bomber"); const bomber = state._spawnUnit(city.coords, 0, bomberProto, city.id); const enemyCity = state.cities.find((c) => c.civ === 1); screen.onState(state.snapshot(0)); const unit = screen._unit(bomber.id); const route = await screen.map.planMove(unit, { x: enemyCity.coords.x, y: enemyCity.coords.y, }); this.assertNotNull(route, "the client mirrors the server into the city tile"); screen.leave(); } finally { teardownDom(env); } } async test_finishing_an_airport_unlocks_aircraft_in_an_open_panel() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); const before = env.$("#city-train-list button").length; this.assertEqual(before, 2, "only infantry and artillery are trainable at first"); // The airport is completed server-side and a fresh snapshot arrives while // the panel is still open. const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); capital.buildings[airport] = 1; screen.onState(state.snapshot(0)); this.assertGreater( env.$("#city-train-list button").length, before, "the aircraft appears in the still-open panel" ); screen.leave(); } finally { teardownDom(env); } } async test_testing_mode_city_panel_offers_free_instant_orders() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.testing = true; screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); this.assertFalse(env.$("#modal-city").hasClass("hidden")); this.assertSize(env.$("#city-train-list .free-button"), 2, "a free button per trainable unit"); env.$("#city-train-list .free-button").first().click(); this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0, free: true, }); env.$("#modal-city .tab[data-tab='buildings']").click(); this.assertGreater( env.$("#city-buildings-list .free-button").length, 0, "a free button per building" ); env.$("#city-buildings-list .free-button").first().click(); this.assertTrue(network.orders[1].free, "the building order is free"); screen.leave(); } finally { teardownDom(env); } } async test_training_modal_lists_the_queue_and_cancels() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); state.requestTrain(capital.id, 0); state.requestTrain(capital.id, 0); screen.onState(state.snapshot(0)); this.assertSize(env.$("#city-train-queue .queue-row"), 1, "contiguous identical units merge"); this.assertTrue( env.$("#city-train-queue .queue-row").first().text().includes("×2"), "the merged row shows how many units it holds" ); this.assertTrue( env.$("#city-train-queue .queue-row").first().text().includes("all ready in"), "the merged row forecasts the whole run" ); env.$("#city-train-queue .queue-row").first().find("button").click(); this.assertEqual(network.orders[0], { type: "cancel_train", city: capital.id, index: 1 }); screen.leave(); } finally { teardownDom(env); } } async test_bottom_panel_information() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onTileRequested(capital.coords); this.assertFalse(env.$("#tile-info").hasClass("hidden")); this.assertTrue(env.$("#tile-coords").text().includes("Tile")); this.assertTrue(env.$("#tile-gdp").text().includes("GDP/capita")); this.assertTrue( env.$("#tile-gdp").text().includes("Capital"), "the tile panel explains the capital GDP bonus" ); screen._renderTileGdp({ base: 10000, factors: [ { label: "Terrain", multiplier: 1 }, { label: "City", multiplier: 2 }, { label: "Battle damage", amount: 0 }, ], total: 20000, }); const gdpText = env.$("#tile-gdp").text(); this.assertFalse(gdpText.includes("Terrain"), "a no-op multiplier is hidden"); this.assertFalse(gdpText.includes("Battle damage"), "a zero modifier is hidden"); this.assertTrue(gdpText.includes("City"), "a real modifier is still shown"); const unit = state.units.find((u) => u.civ === 0); screen.map.onUnitSelected(unit.id); this.assertFalse(env.$("#unit-info").hasClass("hidden")); this.assertTrue(env.$("#unit-name").text().length > 0); this.assertEqual( env.$("#unit-health-label").text(), `${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`, "the panel shows hit points next to the bar" ); screen._showUnit(null); this.assertTrue(env.$("#unit-info").hasClass("hidden")); screen._showCity(null); this.assertTrue(env.$("#city-info").hasClass("hidden")); this.assertNull(screen._unit(999)); this.assertNull(screen._city(999)); screen.leave(); } finally { teardownDom(env); } } async test_keyboard_and_render_loop() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); const before = screen.map.camera.x; env.$(env.window).trigger(env.$.Event("keydown", { key: "d" })); this.assertApprox(screen.map.camera.x, before - 40, 1e-9); env.$(env.window).trigger(env.$.Event("keydown", { key: "a", shiftKey: true })); this.assertApprox(screen.map.camera.x, before - 40 + 120, 1e-9); const zoom = screen.map.camera.zoom; env.$(env.window).trigger(env.$.Event("keydown", { key: "+" })); this.assertGreater(screen.map.camera.zoom, zoom); const zoomedIn = screen.map.camera.zoom; env.$(env.window).trigger(env.$.Event("keydown", { key: "-" })); this.assertLess(screen.map.camera.zoom, zoomedIn); env.$(env.window).trigger(env.$.Event("keydown", { key: "z" })); env.$(env.window).trigger(env.$.Event("keydown", { key: "Escape" })); screen._lastFrame = 0; screen._frame(1000); screen._frame(2000); screen._frame(3000); this.assertTrue(env.$("#fps-label").text().includes("FPS")); screen.leave(); screen._frame(4000); } finally { teardownDom(env); } } async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.research.set(0, 1.0e6); state.budgets.set(0, 1.0e15); const method = state.technologies.findIndex((t) => t.id === "scientific_method"); state.requestResearchTechnology(0, method); screen.onState(state.snapshot(0)); this.assertTrue(screen._researched.has(method), "researched technologies are mirrored"); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); state.requestBuild(capital.id, 0); for (let i = 0; i < 1000 && state.training.size > 0; i++) state.advanceHour(); screen.onState(state.snapshot(0)); this.assertEqual(screen._cityBuildings.get(capital.id)["0"], 1, "building levels are mirrored"); env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); screen.leave(); } finally { teardownDom(env); } } async test_unit_panel_shows_terrain_defense_and_tooltip() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const mountain = state.landCells.find( (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain" ); this.assertNotNull(mountain, "the map has a mountain"); const unit = state._spawnUnit(mountain, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); const data = state.snapshot(0).units.find((u) => u.id === unit.id); screen._showUnit(data); const proto = screen.protoUnits[unit.proto]; this.assertEqual(env.$("#unit-attack").text(), `Attack ${proto.attack}`); this.assertEqual(env.$("#unit-attack").attr("data-tooltip"), `Base ${proto.attack}`); this.assertEqual(env.$("#unit-defense").text(), `Defense ${proto.defense * 2}`); this.assertEqual( env.$("#unit-defense").attr("data-tooltip"), `Base ${proto.defense}, Mountain +${proto.defense}` ); env.$("#unit-defense").trigger("mouseenter"); this.assertFalse(env.$("#stat-tooltip").hasClass("hidden")); this.assertEqual( env.$("#stat-tooltip").text(), `Base ${proto.defense}, Mountain +${proto.defense}` ); env.$("#unit-defense").trigger("mouseleave"); this.assertTrue(env.$("#stat-tooltip").hasClass("hidden")); screen.leave(); } finally { teardownDom(env); } } async test_unit_panel_shows_city_defense_bonus() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const plain = state.landCells.find( (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" && !state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n)) ); this.assertNotNull(plain, "found a plain tile"); state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false }); state._rebuildCityIndex(); const unit = state._spawnUnit(plain, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); screen._showUnit(state.snapshot(0).units.find((u) => u.id === unit.id)); const proto = screen.protoUnits[unit.proto]; this.assertEqual(env.$("#unit-defense").text(), `Defense ${Math.round(proto.defense * 1.5)}`); this.assertEqual( env.$("#unit-defense").attr("data-tooltip"), `Base ${proto.defense}, City +${Math.round(proto.defense * 0.5)}` ); screen.leave(); } finally { teardownDom(env); } } async test_move_into_peaceful_territory_prompts_for_war() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); const goal = { x: 900, y: 900 }; screen.map.territory.set("900,900", 1); screen.map.planMove = () => [{ x: 1, y: 1 }, goal]; let predicted = null; screen.map.predictMove = (u, coords) => { predicted = { u, coords }; }; await screen.map.onMoveOrdered([unit.id], goal); this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens"); this.assertEmpty(network.orders, "nothing is sent before confirming"); this.assertTrue(env.$("#confirm-message").text().includes("Britain")); env.$("#confirm-ok").click(); this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] }); this.assertNotNull(predicted, "the confirmed move is predicted locally"); screen.leave(); } finally { teardownDom(env); } } async test_cancelling_the_war_prompt_sends_nothing() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); const goal = { x: 902, y: 902 }; screen.map.territory.set("902,902", 1); screen.map.planMove = () => [{ x: 1, y: 1 }, goal]; await screen.map.onMoveOrdered([unit.id], goal); env.$("#confirm-cancel").click(); this.assertEmpty(network.orders, "cancelling keeps the peace"); screen.leave(); } finally { teardownDom(env); } } async test_move_into_enemy_territory_skips_the_prompt() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.requestDeclareWar(0, 1); screen.onState(state.snapshot(0)); const unit = state.units.find((u) => u.civ === 0); const goal = { x: 901, y: 901 }; screen.map.territory.set("901,901", 1); screen.map.planMove = () => [{ x: 1, y: 1 }, goal]; screen.map.predictMove = () => null; await screen.map.onMoveOrdered([unit.id], goal); this.assertTrue(env.$("#modal-confirm").hasClass("hidden")); this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] }); screen.leave(); } finally { teardownDom(env); } } async test_diplomacy_tab_sends_a_declare_war_order() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab=diplomacy]").click(); const row = env.$("#diplomacy-list tbody tr").first(); this.assertTrue(row.find(".name").text().length > 0); row.find("button").click(); this.assertEqual(network.orders[0].type, "declare_war"); this.assertEqual(network.orders[0].target, 1); screen.leave(); } finally { teardownDom(env); } } async test_news_feed_shows_declarations() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.requestDeclareWar(0, 1); screen.onState(state.snapshot(0)); env.$("#news-button").click(); this.assertFalse(env.$("#modal-news").hasClass("hidden")); this.assertTrue(env.$("#news-list").text().includes("declared war on Britain")); screen.leave(); } finally { teardownDom(env); } } async test_new_news_pops_up_in_the_corner_feed() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); // The first snapshot is history: it must not flood the feed. screen.onState(state.snapshot(0)); this.assertSize(env.$("#game-feed .feed-line"), 0); state.requestDeclareWar(0, 1); screen.onState(state.snapshot(0)); this.assertSize(env.$("#game-feed .feed-line"), 1); this.assertTrue(env.$("#game-feed .feed-line").text().includes("declared war on Britain")); screen.leave(); } finally { teardownDom(env); } } async test_war_declared_on_the_player_is_an_alert() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); state.requestDeclareWar(1, 0); screen.onState(state.snapshot(0)); const alert = env.$("#game-feed .feed-line.feed-alert"); this.assertSize(alert, 1); this.assertTrue(alert.text().includes("declared war on you")); screen.leave(); this.assertSize(env.$("#game-feed .feed-line"), 0, "leaving clears the feed"); } finally { teardownDom(env); } } async test_stack_menu_selects_all_and_splits_one() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120); this.assertFalse(env.$("#stack-menu").hasClass("hidden")); this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all"); env.$("#stack-menu .stack-all").click(); this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]); this.assertTrue(env.$("#unit-name").text().includes("2 units selected")); this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick"); screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120); env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click(); this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack"); screen.leave(); } finally { teardownDom(env); } } async test_clicking_a_stacked_unit_selects_the_whole_stack() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const unit = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); screen.map.onUnitSelected(unit.id); this.assertEqual( screen.selectedUnitIds.slice().sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b), "the whole stack is selected" ); // The next order is given to every unit in the stack. const goal = adjacentLand(state, unit); await screen.map._onRightClick(goal, {}); this.assertEqual(network.orders[0].type, "move"); this.assertEqual( network.orders[0].units.slice().sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b) ); screen.leave(); } finally { teardownDom(env); } } async test_group_move_uses_the_slowest_speed() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const capital = state.units.find((u) => u.civ === 0); const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]); const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]); screen.onState(state.snapshot(0)); screen._selectUnits([infantry.id, artillery.id]); const goal = adjacentLand(state, capital); await screen.map.onMoveOrdered([infantry.id, artillery.id], goal); this.assertEqual(network.orders[0].type, "move"); this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]); const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0]; const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0]; this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace"); screen.leave(); } finally { teardownDom(env); } } async test_pillage_button_forecasts_and_sends_the_order() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const coords = findEnemyTile(state); this.assertNotNull(coords, "found an enemy tile"); state.tilePopulation.set(`${coords.x},${coords.y}`, 100000); const first = state._spawnUnit(coords, 0, state.protoUnits[0]); const second = state._spawnUnit(coords, 0, state.protoUnits[0]); first.hp = 100; second.hp = 90; screen.onState(state.snapshot(0)); screen._selectUnits([first.id, second.id]); const $button = env.$("#unit-pillage"); this.assertFalse($button.hasClass("hidden"), "the pillage button shows"); const tooltip = $button.attr("data-tooltip") || ""; this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`); this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier"); this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals"); $button.click(); this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] }); screen.leave(); } finally { teardownDom(env); } } async test_pillage_button_hides_in_friendly_land() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const unit = state.units.find((u) => u.civ === 0); screen.onState(state.snapshot(0)); screen._selectUnits([unit.id]); this.assertTrue(env.$("#unit-pillage").hasClass("hidden")); screen.leave(); } finally { teardownDom(env); } } } function findEnemyTile(state) { for (const [k, owner] of state.territory) { if (owner !== 1) continue; const i = k.indexOf(","); const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; if (state.unitAt(coords) || state.cityAt(coords)) continue; return coords; } return null; }