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, TECHNOLOGIES, PROTO_UNITS, SPY_OPERATIONS, TRANSPORT_IMPROVEMENTS, TREATY_PEACE } from "../shared/data.js"; import { hoursInNextMonth } from "../shared/game_clock.js"; import { compact, groupDigits } from "../shared/text_format.js"; import { tileImprovementResourceCost } from "../shared/resources.js"; import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js"; import { parseKey } from "../shared/hex.js"; import { smallState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js"; function stubNetwork() { return { isAdmin: false, localPlayerId: 2, players: { 2: { id: 2, name: "Carol", admin: false } }, orders: [], sendOrder(order) { this.orders.push(order); }, }; } function makeConfig() { return { playerName: "Carol", playerCivId: "france", civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]], }; } // Switches the open Intelligence tab to one of its sub-tabs, by label. function showIntelligenceSubtab(env, label) { env .$("#intelligence-subtabs .subtab") .filter((index, element) => env.$(element).text() === label) .trigger("click"); } // The first populated foreign land tile in the fixture, for a spy to stand on. function foreignPopulatedLand(state, civ = 1) { for (const [k, owner] of state.territory) { if (owner !== civ) continue; const coords = parseKey(k); if (state.cityAt(coords)) continue; if (state.unitsAt(coords).length > 0) continue; const tile = state.tiles[k]; if (!tile || tile.terrainClass !== "Land") continue; if ((state.tilePopulation.get(k) || 0) <= 0) continue; return coords; } return null; } // The city-training cards whose action is live (a locked card's Train button is // disabled), so counts ignore the gated units shown for reference. function enabledTrainCards(env) { return env.$("#city-train-list .building-tile").filter( (index, element) => !env.$(element).find(".bt-upgrade").prop("disabled") ); } 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_versioned_collections_are_reused_when_unchanged() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const population = screen._population; const territory = screen.map.territory; const regions = screen.map.regions; const ethnicity = screen._tileEthnicity; const baseline = screen._productionBaseline; // A second snapshot with no state change carries the same versions, so // none of the big per-tile maps should be rebuilt. screen.onState(state.snapshot(0)); this.assert(population === screen._population, "population map is reused"); this.assert(baseline === screen._productionBaseline, "the growth baseline is reused"); this.assert(territory === screen.map.territory, "territory map is reused"); this.assert(regions === screen.map.regions, "region map is reused"); this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused"); } finally { teardownDom(env); } } 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(); stripPorts(state, 0); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); screen.onState(state.snapshot(0)); this.assertEqual(env.$("#civ-name").text(), state.civilisations[0].name); this.assertEqual(env.$("#player-name").text(), "Carol"); this.assertTrue(env.$("#stat-pop").text() !== "0"); this.assertTrue(env.$("#stat-date").text().includes("2000")); 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(enabledTrainCards(env), 4, "infantry, artillery, radar and missiles can be trained"); enabledTrainCards(env).first().find(".bt-upgrade").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.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 .building-tile"), TECHNOLOGIES.length); screen.leave(); } finally { teardownDom(env); } } async test_top_panel_shows_approval_with_colour() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); this.assertEqual(env.$("#stat-approval").text(), "50%"); this.assertTrue(env.$("#stat-approval").hasClass("approval-neutral")); for (let eth = 0; eth < state.civilisations.length; eth++) { state._shiftGovOpinion(eth, 0, -0.35); } screen.onState(state.snapshot(0)); this.assertTrue( env.$("#stat-approval").hasClass("approval-negative"), `disapproval turns red (${env.$("#stat-approval").text()})` ); for (let eth = 0; eth < state.civilisations.length; eth++) { state._shiftGovOpinion(eth, 0, 0.7); } screen.onState(state.snapshot(0)); this.assertTrue( env.$("#stat-approval").hasClass("approval-positive"), `approval turns green (${env.$("#stat-approval").text()})` ); screen.leave(); } finally { teardownDom(env); } } async test_top_panel_arrows_compare_to_last_month() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); // January 2000 carries no baseline, so no change markers. this.assertSize(env.$("#stat-pop .stat-arrow"), 0, "no arrow without a past month"); const snapshot = state.snapshot(0); const stats = snapshot.viewerStats; snapshot.monthly = [{ population: stats.population / 2, gdp: stats.gdp / 2, gdpPerCapita: stats.gdpPerCapita / 2, popularity: stats.popularity, approval: stats.approval, budget: stats.budget, }]; screen.onState(snapshot); this.assertEqual( env.$("#stat-pop .stat-arrow").text(), "▲▲", "a doubling population shows two green up arrows" ); this.assertTrue(env.$("#stat-pop .stat-arrow").hasClass("up")); this.assertEqual( env.$("#stat-approval .stat-arrow").text(), "=", "an unchanged popularity shows an equals sign" ); this.assertTrue(env.$("#stat-approval .stat-arrow").hasClass("equal")); 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 .building-tile"), 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("Trade taxes"), "region tax is listed"); this.assertTrue(text.includes("TOTAL"), "the region net is totalled"); screen.leave(); } finally { teardownDom(env); } } async test_spies_are_trainable_once_the_agency_is_researched() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.onState(state.snapshot(0)); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); const spyCard = () => env.$("#city-train-list .building-tile").filter( (index, element) => env.$(element).find(".bt-name").first().text() === "Spy" ); this.assertSize(spyCard(), 1, "the spy is listed even before the agency"); this.assertTrue(spyCard().find(".bt-upgrade").prop("disabled"), "but its card is locked"); this.assertTrue( spyCard().text().includes("Requires Intelligence Agencies"), "and names what it needs" ); const tech = state.technologies.findIndex((t) => t.id === "intelligence_agencies"); state.researched.get(0).add(tech); screen.onState(state.snapshot(0)); this.assertFalse( spyCard().find(".bt-upgrade").prop("disabled"), "the spy is trainable after the agency" ); screen.leave(); } finally { teardownDom(env); } } async test_testing_mode_offers_instant_technology_unlock() { 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)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='technology']").click(); const $unlock = env.$("#technology-list .free-button").filter( (index, element) => env.$(element).text() === "Unlock now" ); this.assertGreater($unlock.length, 0, "a testing game offers an instant unlock"); $unlock.first().trigger("click"); const order = network.orders.find((entry) => entry.type === "unlock_technology"); this.assertNotNull(order, "the unlock order is sent"); this.assertTrue(order.free, "flagged as the testing shortcut"); 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); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); screen.onState(state.snapshot(0)); 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_air_strike_without_munitions_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); const goal = adjacentLand(state, home); state._spawnUnit(goal, 1, state.protoUnits[0]); const snapshot = state.snapshot(0); // The server charges an air strike in munitions, not money: without the // steel and high-tech in store the browser must refuse it. snapshot.viewerStats.resources.stock.steel = 0; snapshot.viewerStats.resources.stock.hightech = 0; screen.onState(snapshot); await screen._orderMove([screen._unit(jet.id)], goal, "attack"); this.assertEqual(network.orders.length, 0, "no order is sent without the munitions"); this.assertEqual(env.$("#confirm-title").text(), "Insufficient munitions"); screen.leave(); } finally { teardownDom(env); } } async test_air_strike_with_munitions_is_allowed() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), 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); screen.onState(state.snapshot(0)); this.assertNull( screen._airStrikeBlocked([screen._unit(jet.id)]), "full stores arm the aircraft" ); screen.leave(); } finally { teardownDom(env); } } async test_air_strike_on_a_fogged_neutral_city_asks_to_declare_war() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const home = state.cities.find((c) => c.civ === 0); grantBuilding(state, home, "airport"); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto, home.id); const city = state.cities.find((c) => c.civ === 1); // Defenceless and under the fog: no garrison is visible, but a known city // tile is still a legitimate air-strike target. for (const unit of state.unitsAt(city.coords).slice()) state._destroyUnit(unit); screen.onState(state.snapshot(0)); screen._selectUnits([jet.id]); this.assertTrue( screen._isEnemyCityAt({ x: city.coords.x, y: city.coords.y }), "the fogged city is a target" ); screen._toggleTargeting(); this.assertNotNull(screen._targeting, "targeting is armed"); screen._chooseTarget({ x: city.coords.x, y: city.coords.y }); for (let i = 0; i < 100 && env.$("#confirm-title").text() !== "Declare war?"; i++) { await new Promise((resolve) => setTimeout(resolve, 5)); } this.assertEqual( env.$("#confirm-title").text(), "Declare war?", "attacking a neutral city asks first, exactly like marching in" ); env.$("#confirm-ok").trigger("click"); const order = network.orders.find((entry) => entry.type === "attack"); this.assertNotNull(order, "confirming sends the air strike"); this.assertEqual(order.coords[0], city.coords.x); this.assertEqual(order.coords[1], city.coords.y); 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(); stripPorts(state, 0); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); screen.onState(state.snapshot(0)); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); const before = enabledTrainCards(env).length; this.assertEqual(before, 4, "only infantry, artillery, radar and missiles 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( enabledTrainCards(env).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; stripPorts(state, 0); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); screen.onState(state.snapshot(0)); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); this.assertFalse(env.$("#modal-city").hasClass("hidden")); this.assertSize(env.$("#city-train-list .free-button"), 4, "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(); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); screen.onState(state.snapshot(0)); 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")); // The panel leads with what the tile produces, and GDP is only shown for // its region. this.assertTrue( env.$("#tile-gdp").text().includes("Region GDP/capita"), "GDP is shown at the region level" ); this.assertGreater( env.$("#tile-production .tile-prod-row").length, 0, "the tile panel lists the resources it produces" ); this.assertTrue( env.$("#tile-production").text().includes("Food"), "the capital's people grow food" ); 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_tile_and_unit_panels_dock_independently() { 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); // Inspecting a tile opens the bottom-right tile panel on its own. screen.map.onTileRequested(capital.coords); this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens"); this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the unit panel stays shut"); // Selecting a unit opens the bottom-left unit panel and leaves the tile // panel showing the inspected tile. const unit = state.units.find((u) => u.civ === 0); screen.map.onUnitSelected(unit.id); this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens"); this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel stays open"); // Clearing the selection (an empty left-click) dismisses both. screen.map.clearSelection(); this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "the click clears the tile panel"); this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the click clears the unit panel"); // ESC over the map does the same. screen.map.onTileRequested(capital.coords); screen.map.onUnitSelected(unit.id); env.$(env.window).trigger(env.$.Event("keydown", { key: "Escape" })); this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "ESC clears the tile panel"); this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "ESC clears the unit panel"); 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_chat_opens_with_t_and_sends_lines() { const env = await setupDom(); try { const network = stubNetwork(); const sent = []; network.sendChat = (text) => sent.push(text); const screen = new GameScreen(network, makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); this.assertTrue(env.$("#chat").hasClass("hidden"), "the chat starts closed"); env.$(env.window).trigger(env.$.Event("keydown", { key: "t" })); this.assertFalse(env.$("#chat").hasClass("hidden"), "T opens the chat"); // The map must not pan while the chat input holds focus. const cameraBefore = screen.map.camera.x; env.$("#chat-input").trigger(env.$.Event("keydown", { key: "d" })); this.assertApprox(screen.map.camera.x, cameraBefore, 1e-9, "typing does not pan the map"); screen.addChat({ name: "Bob", civ: 1, text: "hello there" }); const line = env.$("#chat-log .chat-line").last(); this.assertTrue(line.find(".chat-author").text().includes("Bob")); this.assertTrue(line.find(".chat-text").text().includes("hello there")); env.$("#chat-input").val("good luck"); env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Enter" })); this.assertEqual(sent, ["good luck"], "the line is sent to the server"); this.assertEqual(env.$("#chat-input").val(), "", "the input clears after sending"); env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Escape" })); this.assertTrue(env.$("#chat").hasClass("hidden"), "Escape closes the chat"); screen.leave(); } finally { teardownDom(env); } } async test_a_treaty_offer_becomes_a_clickable_feed_line() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); // The first snapshot only seeds the seen-news baseline. screen.onState(state.snapshot(0)); state.requestProposeTreaty(1, 0, TREATY_PEACE); screen.onState(state.snapshot(0)); const line = env.$("#game-feed .feed-line").last(); this.assertTrue(line.text().includes("proposes Peace"), "the offer hits the feed"); this.assertTrue(line.hasClass("feed-clickable"), "and can be clicked"); line.trigger("click"); this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the offer dialog opens"); this.assertTrue(env.$("#confirm-ok").text().includes("Sign"), "with a Sign button"); env.$("#confirm-ok").trigger("click"); const order = network.orders.at(-1); this.assertEqual(order.type, "respond_treaty"); this.assertTrue(order.accept, "Signing accepts the offer"); screen.leave(); } 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.budgets.set(0, 1.0e15); const method = state.technologies.findIndex((t) => t.id === "scientific_method"); state.requestFocusTechnology(0, method); state._addResearch(0, state.getTechnologyCost(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_the_city_button_opens_a_city_when_its_unit_is_selected() { 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)) ); 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.map.onUnitSelected(unit.id); this.assertFalse(env.$("#city-info").hasClass("hidden"), "the city panel is shown"); this.assertFalse(env.$("#city-manage").hasClass("hidden"), "the city button is available"); env.$("#city-manage").click(); this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the button opens the city modal"); screen.leave(); } finally { teardownDom(env); } } async test_double_clicking_a_unit_in_a_city_opens_the_city() { 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)) ); 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)); const view = screen.map._unitViews.get(unit.id); this.assertNotNull(view, "the unit is drawn"); view.dispatchEvent(new env.window.MouseEvent("dblclick", { bubbles: true })); this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city opens"); screen.leave(); } finally { teardownDom(env); } } async test_stack_deck_is_laid_out_after_the_panel_is_shown() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const home = state.units.find((u) => u.civ === 0); state._spawnUnit(home.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); const snap = screen.snapshot.units.find((u) => u.civ === 0); // Records whether the panel was already on screen when the deck was // measured, so a hidden panel (zero-width cards, no overlap) is caught. let visibleAtLayout = null; const layout = screen._layoutStackDecks.bind(screen); screen._layoutStackDecks = () => { visibleAtLayout = !env.$("#unit-info").hasClass("hidden"); layout(); }; screen.map.onUnitSelected(snap.id); this.assertGreater(env.$("#unit-stack-list .stack-unit-tile").length, 1, "the deck rendered"); this.assertTrue(visibleAtLayout, "the deck is measured only once the panel is shown"); screen.leave(); } finally { teardownDom(env); } } async test_shift_detach_splits_the_stack_into_a_moving_group() { 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 a = home; const b = state._spawnUnit(home.coords, 0, state.protoUnits[0]); const c = state._spawnUnit(home.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); const sorted = (list) => list.slice().sort((x, y) => x - y); const all = sorted([a.id, b.id, c.id]); screen.map.onUnitSelected(a.id); this.assertEqual(sorted(screen.selectedUnitIds), all, "a click takes the whole stack"); screen.map.onUnitToggled(b.id); this.assertEqual( sorted(screen.selectedUnitIds), sorted([a.id, c.id]), "shift detaches one unit from the moving group" ); this.assertTrue( screen._stackMembers.includes(b.id), "the detached card stays in the panel, so a mis-click is recoverable" ); screen.map.onUnitToggled(b.id); this.assertEqual(screen.selectedUnitIds.length, 3, "toggling it again re-attaches it"); screen.map.onUnitToggled(b.id); const goal = state.topology .neighbours(home.coords.x, home.coords.y) .find((n) => state._isLand(n) && state.civAt(n) <= 0 && !state.unitAt(n)); this.assertNotNull(goal, "the fixture has an empty own-land neighbour"); await screen.map.onMoveOrdered(screen.selectedUnitIds.slice(), goal); const order = network.orders.at(-1); this.assertEqual(order.type, "move"); this.assertEqual( sorted(order.units), sorted([a.id, c.id]), "only the ticked subset marches" ); screen.leave(); } finally { teardownDom(env); } } async test_the_stat_tooltip_sits_outside_the_hud() { const env = await setupDom(); try { this.assertSize(env.$("#hud #stat-tooltip"), 0, "the tooltip is not trapped in the HUD"); this.assertEqual(env.$("#screen-game > #stat-tooltip").length, 1, "it is a sibling of the HUD, so it can paint above the unit panel"); } finally { teardownDom(env); } } async test_the_client_picks_the_nearest_return_airport() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const cities = state.cities.filter((c) => c.civ === 0); for (const city of cities) grantBuilding(state, city, "airport"); const [home, away] = cities; const jet = state.protoUnits.find((p) => p.id === "jet_fighter"); const unit = state._spawnUnit(home.coords, 0, jet, home.id); screen.onState(state.snapshot(0)); const data = screen.snapshot.units.find((u) => u.id === unit.id); const beside = state.topology .neighbours(away.coords.x, away.coords.y) .filter((n) => state._isLand(n)) .sort((a, b) => state.topology.tileDistance(home.coords, b) - state.topology.tileDistance(home.coords, a) )[0]; this.assertNotNull(beside, "a land tile beyond the far city"); const airport = screen._airReturnAirport(data, beside); this.assertNotNull(airport, "there is a return airport"); this.assertEqual(airport.id, away.id, "the nearest airport, not the home base"); screen.leave(); } finally { teardownDom(env); } } async test_a_local_config_offers_the_free_buttons() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), { ...makeConfig(), testing: true }); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); this.assertTrue(screen.testing, "a local page turns on the testing buttons"); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); this.assertGreater(env.$("#modal-city .free-button").length, 0, "the city offers free orders"); screen.leave(); } finally { teardownDom(env); } } async test_unit_panel_shows_transport_cargo_and_boarding() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const launchProto = state.protoUnits.find((u) => u.id === "launch"); const infantryProto = state.protoUnits.find((u) => u.id === "modern_infantry"); const seaKey = Object.keys(state.tiles).find( (k) => state.tiles[k].terrainClass === "Sea" ); this.assertNotNull(seaKey, "the map has a sea tile"); const spot = parseKey(seaKey); const launch = state._spawnUnit(spot, 0, launchProto); launch.cargo = [{ proto: state.protoUnits.indexOf(infantryProto), hp: infantryProto.maxHp }]; launch.embarking = { ids: [99], hoursLeft: 6 }; const snapshot = state.snapshot(0); screen.onState(snapshot); screen._showUnit(snapshot.units.find((u) => u.id === launch.id)); const cargo = env.$("#unit-cargo"); this.assertFalse(cargo.hasClass("hidden")); this.assertTrue(cargo.text().includes("Cargo 1/1"), "the berths are shown"); this.assertTrue(cargo.text().includes(infantryProto.name), "the passenger is named"); this.assertTrue(cargo.text().includes("boarding 1"), "a boarding party is shown"); screen.leave(); } finally { teardownDom(env); } } async test_unit_panel_hides_cargo_for_a_plain_unit() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const spot = state.landCells.find( (c) => state.tiles[`${c.x},${c.y}`].terrainClass === "Land" && !state.cityAt(c) ); const unit = state._spawnUnit(spot, 0, state.protoUnits[0]); const snapshot = state.snapshot(0); screen.onState(snapshot); screen._showUnit(snapshot.units.find((u) => u.id === unit.id)); this.assertTrue(env.$("#unit-cargo").hasClass("hidden")); 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(state.civilisations[1].name)); 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:not(.local-nation)").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_focusing_a_technology_sends_the_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=technology]").click(); const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); env.$("#technology-list .building-tile") .eq(method) .find(".bt-upgrade") .click(); this.assertEqual(network.orders[0], { type: "focus_technology", technology: method, }); 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 ${state.civilisations[1].name}`)); 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 ${state.civilisations[1].name}`)); 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\u00a0000"), `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); } } async test_politics_tab_sends_campaign_and_policy_orders() { 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='intelligence']").click(); this.assertTrue(env.$("#tab-intelligence").hasClass("active")); showIntelligenceSubtab(env, "Propaganda"); this.assertGreater(env.$("#intelligence-propaganda-view .politics-card").length, 0); const $start = env.$("#intelligence-propaganda-view button").filter( (index, element) => env.$(element).text() === "Start campaign" ); this.assertSize($start, 1); $start.trigger("click"); const campaign = network.orders.find((order) => order.type === "campaign"); this.assertNotNull(campaign); this.assertEqual(campaign.observer, 0); this.assertEqual(campaign.targetKind, "government"); this.assertEqual(campaign.target, 1); env.$("#modal-nation .tab[data-tab='politics']").click(); this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden")); const $new = env.$("#tab-politics button").filter( (index, element) => env.$(element).text() === "New minority policy" ); this.assertSize($new, 1); $new.trigger("click"); this.assertFalse(env.$("#tab-politics .politics-wizard").hasClass("hidden")); const $enactNow = env.$("#tab-politics button").filter( (index, element) => env.$(element).text() === "Enact policy" ); this.assertSize($enactNow, 1); $enactNow.trigger("click"); const policy = network.orders.find((order) => order.type === "policy"); this.assertNotNull(policy); this.assertEqual(policy.policy, "champion"); this.assertEqual(policy.ethnicity, 1); screen.leave(); } finally { teardownDom(env); } } async test_intelligence_tab_hosts_propaganda() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='intelligence']").click(); this.assertTrue(env.$("#tab-intelligence").hasClass("active")); showIntelligenceSubtab(env, "Propaganda"); const $launcher = env.$("#intelligence-propaganda-view button").filter( (index, element) => env.$(element).text() === "Start campaign" ); this.assertSize($launcher, 1, "the propaganda launcher lives on the Intelligence tab"); const $onPolitics = env.$("#tab-politics button").filter( (index, element) => env.$(element).text() === "Start campaign" ); this.assertSize($onPolitics, 0, "and is no longer on the Politics tab"); screen.leave(); } finally { teardownDom(env); } } async test_intelligence_subtabs_split_spies_and_propaganda() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='intelligence']").click(); const $tabs = env.$("#intelligence-subtabs .subtab"); this.assertSize($tabs, 2, "the Intelligence tab has two sub-tabs"); this.assertEqual($tabs.eq(0).text(), "Spies"); this.assertEqual($tabs.eq(1).text(), "Propaganda"); // The spy controls open first, the campaigns stay out of the way. this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden")); this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden")); showIntelligenceSubtab(env, "Propaganda"); this.assertTrue(env.$("#intelligence-spies-view").hasClass("hidden")); this.assertFalse(env.$("#intelligence-propaganda-view").hasClass("hidden")); showIntelligenceSubtab(env, "Spies"); this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden")); this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden")); screen.leave(); } finally { teardownDom(env); } } async test_intelligence_tab_lists_the_viewers_spies_and_locates_them() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const home = state.cities.find((city) => city.civ === 0); const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy")); screen.onState(state.snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='intelligence']").click(); const $spies = env.$("#intelligence-spies-view .politics-card").filter( (index, element) => env.$(element).find(".name").first().text() === "Your spies" ); this.assertSize($spies, 1, "the roster card is on the Intelligence tab"); this.assertTrue($spies.text().includes("Spy 1"), "the spy is listed"); this.assertTrue($spies.text().includes(home.name), "with the city it stands in"); const $locate = $spies.find("button").filter( (index, element) => env.$(element).text() === "Locate" ); this.assertSize($locate, 1, "the roster offers a Locate control"); $locate.trigger("click"); this.assertTrue(screen.selectedUnitIds.includes(spy.id), "Locate selects the spy"); this.assertFalse(screen.nationModal.isShowing(), "and closes the nation panel"); screen.leave(); } finally { teardownDom(env); } } async test_spy_actions_live_in_the_unit_panel() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const home = state.cities.find((city) => city.civ === 0); const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy")); const soldier = state._spawnUnit( home.coords, 0, state.protoUnits.find((u) => u.id === "modern_infantry") ); screen.onState(state.snapshot(0)); // A spy's covert operations are unit actions, so they appear in the unit // panel once the spy is selected. screen._selectUnits([spy.id]); const $actions = env.$("#unit-spy-actions"); this.assertFalse($actions.hasClass("hidden"), "the spy actions row shows for a spy"); const $buttons = $actions.find("button"); this.assertSize($buttons, SPY_OPERATIONS.length, "one button per operation"); for (const operation of SPY_OPERATIONS) { const $button = $buttons.filter( (index, element) => env.$(element).attr("data-operation") === operation.id ); this.assertSize($button, 1, `a button for ${operation.id}`); this.assertTrue($button.prop("disabled"), `${operation.id} is not wired yet`); } // A soldier gets no spy actions. screen._selectUnits([soldier.id]); this.assertTrue( env.$("#unit-spy-actions").hasClass("hidden"), "hidden for a soldier" ); // And the tab no longer lists the operations. env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='intelligence']").click(); const $operations = env.$("#intelligence-spies-view .politics-card").filter( (index, element) => env.$(element).find(".name").first().text() === "Covert operations" ); this.assertSize($operations, 0, "the operations card is not on the Intelligence tab"); screen.leave(); } finally { teardownDom(env); } } async test_sabotage_is_offered_on_foreign_land_and_sent() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const target = foreignPopulatedLand(state, 1); this.assertNotNull(target, "the fixture has a populated foreign tile"); const spy = state._spawnUnit(target, 0, state.protoUnits.find((u) => u.id === "spy")); screen.onState(state.snapshot(0)); screen._selectUnits([spy.id]); const $sabotage = env.$("#unit-spy-actions").find("[data-operation='sabotage']"); this.assertSize($sabotage, 1, "the sabotage button exists"); this.assertFalse($sabotage.prop("disabled"), "sabotage is offered on foreign land"); $sabotage.trigger("click"); const order = network.orders.find((entry) => entry.type === "sabotage"); this.assertNotNull(order, "the sabotage order is sent"); this.assertTrue(order.units.includes(spy.id), "for the selected spy"); screen.leave(); } finally { teardownDom(env); } } async test_city_spying_arms_targeting_and_sends() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const city = state.cities.find((entry) => entry.civ === 1); const spy = state._spawnUnit( { x: city.coords.x, y: city.coords.y }, 0, state.protoUnits.find((u) => u.id === "spy") ); screen.onState(state.snapshot(0)); screen._selectUnits([spy.id]); const $button = env.$("#unit-spy-actions").find("[data-operation='city_spying']"); this.assertSize($button, 1); this.assertFalse($button.prop("disabled"), "a city is in reach"); $button.trigger("click"); this.assertNotNull(screen._targeting, "picking a target is armed"); screen._chooseTarget({ x: city.coords.x, y: city.coords.y }); const order = network.orders.find((entry) => entry.type === "spy_city"); this.assertNotNull(order, "the city spy order is sent"); this.assertEqual(order.coords[0], city.coords.x); this.assertTrue(order.units.includes(spy.id)); screen.leave(); } finally { teardownDom(env); } } async test_launch_satellite_button_sends() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); const city = state.cities.find((entry) => entry.civ === 0); grantBuilding(state, city, "space_launch_center", 1); screen.onState(state.snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='intelligence']").click(); const $launch = env.$("#intelligence-spies-view button").filter( (index, element) => env.$(element).text() === "Launch spy satellite" ); this.assertSize($launch, 1, "the launch control is on the Spies sub-tab"); this.assertFalse($launch.prop("disabled"), "a free pad is available"); $launch.trigger("click"); const order = network.orders.find((entry) => entry.type === "launch_satellite"); this.assertNotNull(order, "the launch order is sent"); this.assertEqual(order.city, city.id); screen.leave(); } finally { teardownDom(env); } } async test_a_spy_boards_a_launch_on_a_right_click() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); let land = null; let sea = null; for (const cell of state.landCells) { if (state.civAt(cell) !== 0 || state.unitAt(cell) || state.cityAt(cell)) continue; for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { const tile = state.tiles[`${neighbour.x},${neighbour.y}`]; if (tile && tile.terrainClass === "Sea" && !state.unitAt(neighbour)) { land = cell; sea = neighbour; break; } } if (land) break; } this.assertNotNull(land, "the nation has an empty coastal tile"); const spy = state._spawnUnit(land, 0, state.protoUnits.find((u) => u.id === "spy")); const launch = state._spawnUnit(sea, 0, state.protoUnits.find((u) => u.id === "launch")); screen.onState(state.snapshot(0)); await screen.map.onMoveOrdered([spy.id], { x: sea.x, y: sea.y }); const order = network.orders.find((entry) => entry.type === "embark"); this.assertNotNull(order, "the spy boards"); this.assertEqual(order.launch, launch.id); screen.leave(); } finally { teardownDom(env); } } async test_politics_tab_survives_snapshot_refresh() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='politics']").click(); env .$("#tab-politics button") .filter((index, element) => env.$(element).text() === "New minority policy") .trigger("click"); const $selects = env.$("#tab-politics .politics-wizard select"); $selects.eq(0).val("expel").trigger("change"); $selects.eq(1).val("1").trigger("change"); // A snapshot arrives while the panel is open: the wizard and its choices // must not be torn down. screen.onState(state.snapshot(0)); this.assertFalse( env.$("#tab-politics .politics-wizard").hasClass("hidden"), "the wizard stays open" ); this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(0).val(), "expel"); this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(1).val(), "1"); screen.leave(); } finally { teardownDom(env); } } async test_politics_cancel_campaign_and_duplicate_guard() { 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='intelligence']").click(); showIntelligenceSubtab(env, "Propaganda"); // Inject a running campaign owned by the player. const state = smallState(); state.budgets.set(0, 1.0e15); state.requestCampaign(0, 0, "government", 1, 1); screen.onState(state.snapshot(0)); const $cancel = env.$("#intelligence-propaganda-view button").filter( (index, element) => env.$(element).text() === "Cancel campaign" ); this.assertSize($cancel, 1); $cancel.trigger("click"); this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign"); // The launcher now refuses to duplicate the running campaign. const $start = env.$("#intelligence-propaganda-view button").filter( (index, element) => env.$(element).text() === "Start campaign" ); this.assertTrue($start.prop("disabled"), "duplicate campaign blocked"); screen.leave(); } finally { teardownDom(env); } } async test_politics_campaign_spending_controls() { 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='intelligence']").click(); showIntelligenceSubtab(env, "Propaganda"); const state = smallState(); state.budgets.set(0, 1.0e15); state.requestCampaign(0, 0, "government", 1, 1); screen.onState(state.snapshot(0)); const $slider = env.$("#intelligence-propaganda-view .politics-slider"); this.assertSize($slider, 1); $slider.val(20000).trigger("change"); const spending = network.orders.find((order) => order.type === "campaign_spending"); this.assertNotNull(spending); this.assertEqual(spending.amount, 20000); // Pushing it to zero asks first, and only stops on confirmation. network.orders.length = 0; $slider.val(0).trigger("change"); this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "confirmation asked"); this.assertNull( network.orders.find((order) => order.type === "cancel_campaign"), "nothing sent before confirming" ); env.$("#confirm-ok").click(); this.assertNotNull(network.orders.find((order) => order.type === "cancel_campaign")); screen.leave(); } finally { teardownDom(env); } } async test_politics_opinion_breakdown() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab='politics']").click(); this.assertGreater(env.$("#tab-politics .opinion-row").length, 0); const headings = env.$("#tab-politics .opinion-heading"); this.assertEqual(headings.eq(0).text(), "Governments"); this.assertEqual(headings.eq(1).text(), "Peoples"); screen.leave(); } finally { teardownDom(env); } } async test_tile_detail_shows_ethnic_makeup() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); screen.onState(state.snapshot(0)); const k = Array.from(state.tilePopulation.keys()).find((key) => { const parts = key.split(",").map(Number); return state._isLand({ x: parts[0], y: parts[1] }); }); const parts = k.split(",").map(Number); screen._showTile({ x: parts[0], y: parts[1] }); this.assertGreater(env.$("#tile-ethnicity .ethnic-legend-row").length, 0); this.assertTrue(env.$("#tile-ethnicity .ethnic-legend-row").first().text().includes("(")); screen.leave(); } finally { teardownDom(env); } } async test_city_people_tab_shows_ethnic_makeup() { 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((city) => city.civ === 0 && city.isCapital); screen.map.onCitySelected(capital.id); env.$("#city-manage").click(); env.$("#modal-city .tab[data-tab='people']").click(); this.assertTrue(env.$("#city-tab-people").hasClass("active")); this.assertGreater(env.$("#city-people-list .ethnic-chart").length, 0); this.assertGreater(env.$("#city-people-list .ethnic-legend-row").length, 0); screen.leave(); } finally { teardownDom(env); } } async test_improvement_buttons_send_build_and_remove_orders() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 10_000_000_000); screen.onState(state.snapshot(0)); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.roads.has(k) && !state.railways.has(k) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); screen._showTile(coords); env.$("#tile-manage").click(); const options = env.$("#tile-manage-improvements .transport-btn"); this.assertEqual(options.length, 2, "road and railway are listed"); this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable"); this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable"); // Each card shows the work's icon and name, while the resource bill is in // the hover tooltip and the estimated price sits on the button. const roadCard = options.eq(0).closest(".building-tile"); this.assertTrue( roadCard.find("img.building-icon").attr("src").endsWith("icon_road.svg"), "the road card shows its icon" ); this.assertTrue(roadCard.find(".bt-name").text() === "Road", "the card names the work"); options.eq(0).trigger("mouseenter"); const tipCost = env.$(".card-tooltip .tip-cost").text(); this.assertTrue(tipCost.includes("50\u00a0000 t"), `the steel cost is shown: ${tipCost}`); this.assertTrue(tipCost.includes("100 GWh"), `the energy cost is shown: ${tipCost}`); this.assertEqual( env.$(".card-tooltip .tip-cost img").length, 2, "steel and energy carry resource icons" ); options.eq(0).trigger("mouseleave"); // The money figure is the build cost plus the material bill at today's // market prices, not the raw build cost. const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road"); const expectedCost = quotedBuildCost( roadProto.buildCost, tileImprovementResourceCost(roadProto), marketPricesFrom(screen.snapshot) ); this.assertTrue( options.eq(0).find(".bt-price").text().includes(groupDigits(Math.round(expectedCost))), `the market price is shown: ${options.eq(0).find(".bt-price").text()}` ); options.eq(0).click(); const order = network.orders[network.orders.length - 1]; this.assertEqual(order.type, "build_improvement"); this.assertApprox(order.coords[0], coords.x); this.assertApprox(order.coords[1], coords.y); this.assertEqual(order.improvement, "road"); // A tile that already carries the improvement greys it out, keeps the // upgrade and offers a remove control. The seeded starter industry also // puts works on network tiles, so clear any off this one first. const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0]; const roadKey = `${road[0]},${road[1]}`; state.tileImprovements.delete(roadKey); state.tileImprovementOwner.delete(roadKey); state.tileImprovementHp.delete(roadKey); screen.onState(state.snapshot(0)); screen._showTile({ x: road[0], y: road[1] }); env.$("#tile-manage").click(); const again = env.$("#tile-manage-improvements .transport-btn"); this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out"); this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available"); const $remove = env.$("#tile-manage-improvements .improvement-btn.remove:not(:disabled)"); this.assertSize($remove, 1, "a remove control is offered"); $remove.click(); this.assertEqual(network.orders[network.orders.length - 1], { type: "remove_improvement", coords: [road[0], road[1]], }); screen.leave(); } finally { teardownDom(env); } } async test_improvement_buttons_are_grouped_by_category() { 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 coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); screen._showTile(coords); env.$("#tile-manage").click(); const labels = env.$("#tile-manage-improvements .improvement-category") .map((_, el) => env.$(el).text()) .get(); this.assertEqual( labels.join(","), "Military,Transportation,Economic", "the categories are labelled in order" ); const firstClasses = env.$("#tile-manage-improvements .improvement-row") .map((_, el) => env.$(el).find(".improvement-btn").first().attr("class")) .get(); this.assertTrue(firstClasses[0].includes("tile-btn"), "military works come first"); this.assertTrue(firstClasses[1].includes("transport-btn"), "transport comes second"); this.assertTrue(firstClasses[2].includes("resource-btn"), "economic producers come last"); screen.leave(); } finally { teardownDom(env); } } async test_a_negative_budget_never_greys_out_an_improvement() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); // The treasury is far in the red: any order is financed by the central // bank as debt, so the buttons must stay live. state.budgets.set(0, -5_000_000); screen.onState(state.snapshot(0)); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); screen._showTile(coords); env.$("#tile-manage").click(); this.assertFalse( env.$("#tile-manage-improvements .transport-btn").eq(0).prop("disabled"), "transport stays clickable in debt" ); const military = env.$("#tile-manage-improvements .improvement-row") .eq(0).find(".tile-btn").first(); this.assertFalse(military.prop("disabled"), "a military work stays clickable in debt"); screen.leave(); } finally { teardownDom(env); } } async test_a_tile_under_construction_offers_only_a_cancel() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); this.assertTrue( state.requestBuildTileImprovement(0, coords, "radar_tower"), "the work is ordered" ); screen.onState(state.snapshot(0)); screen._showTile(coords); env.$("#tile-manage").click(); // Every improvement button is gone while the work runs. this.assertSize(env.$("#tile-manage-improvements .improvement-group"), 0, "nothing is left"); this.assertSize(env.$("#tile-manage-improvements .tile-btn"), 0, "no military or economic works"); this.assertSize(env.$("#tile-manage-improvements .transport-btn"), 0, "not even transport"); // The only control is the cancel beside the progress. const $cancel = env.$("#tile-works .site-actions button"); this.assertSize($cancel, 1, "the work can still be cancelled"); this.assertTrue($cancel.text().includes("Cancel"), `it cancels the work: ${$cancel.text()}`); $cancel.click(); const order = network.orders[network.orders.length - 1]; this.assertEqual(order.type, "cancel_site"); screen.leave(); } finally { teardownDom(env); } } async test_a_stalled_site_raises_its_budget_without_a_button() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered"); // Force a stall with materials still to buy and the market up sharply, // but a supplier within reach so the budget can climb. const site = state.constructionSiteAt(coords); site.budget = 1000; site.phase = "materials"; site.needed.steel = 100; site.bought.steel = 0; site.stalled = true; state.setResourcePrice("steel", state.getResourcePrice("steel") * 10); const snapshot = state.snapshot(0); const entry = (snapshot.constructionSites[0] || []).find( (row) => row.coords[0] === coords.x && row.coords[1] === coords.y ); entry.unreachable = []; screen.onState(snapshot); screen._showTile(coords); const labels = env.$("#tile-works .site-material-label") .map((_, el) => env.$(el).text()) .get() .join("|"); this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`); this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`); this.assertEqual( env.$("#tile-works .site-warning").length, 0, "a reachable site keeps no warning sign" ); const $actions = env.$("#tile-works .site-actions button"); this.assertEqual($actions.length, 1, "only one control is left"); this.assertTrue( $actions.eq(0).text().includes("Cancel"), `cancelling stays: ${$actions.eq(0).text()}` ); screen.leave(); } finally { teardownDom(env); } } async test_a_stalled_site_with_no_supplier_says_so() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered"); const site = state.constructionSiteAt(coords); site.budget = 1000; site.phase = "materials"; site.needed.steel = 100; site.bought.steel = 0; site.stalled = true; const snapshot = state.snapshot(0); const entry = (snapshot.constructionSites[0] || []).find( (row) => row.coords[0] === coords.x && row.coords[1] === coords.y ); // No reachable source holds the steel: no budget can unstall the work, so // this is the only case that keeps the warning sign. entry.unreachable = ["steel"]; screen.onState(snapshot); screen._showTile(coords); const labels = env.$("#tile-works .site-material-label") .map((_, el) => env.$(el).text()) .get() .join("|"); this.assertTrue(labels.includes("No supplier"), `the missing supplier is shown: ${labels}`); this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`); this.assertEqual( env.$("#tile-works .site-warning").length, 1, "the warning sign marks the missing supplier" ); const $actions = env.$("#tile-works .site-actions button"); this.assertEqual($actions.length, 1, "only the cancel button remains"); this.assertTrue($actions.eq(0).text().includes("Cancel"), "cancelling still works"); $actions.eq(0).click(); this.assertEqual( network.orders[network.orders.length - 1], { type: "cancel_site", coords: [coords.x, coords.y] } ); screen.leave(); } finally { teardownDom(env); } } async test_improvement_cost_tracks_market_prices() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); screen.onState(state.snapshot(0)); screen._showTile(coords); env.$("#tile-manage").click(); const before = env.$("#tile-manage-improvements .transport-btn").eq(0) .find(".bt-price").text(); // A jump in the world steel price raises the quoted price, without // changing the amount of steel shown. state.setResourcePrice("steel", state.getResourcePrice("steel") * 3); screen.onState(state.snapshot(0)); screen._showTile(coords); const after = env.$("#tile-manage-improvements .transport-btn").eq(0) .find(".bt-price").text(); this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`); // The steel amount itself is unchanged, in the tooltip. env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseenter"); this.assertTrue( env.$(".card-tooltip .tip-cost").text().includes("50\u00a0000 t"), "the steel amount is unchanged" ); env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseleave"); screen.leave(); } finally { teardownDom(env); } } async test_tile_panel_shows_improvement_build_progress() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.unitAt(cell) && !state.tileImprovements.has(k); }); this.assertNotNull(coords, "the player owns a bare land tile"); this.assertTrue( state.requestBuildTileImprovement(0, coords, "radar_tower"), "the work is queued" ); state.advanceHour(); screen.onState(state.snapshot(0)); screen._showTile(coords); const label = env.$("#tile-works .city-production-label").text(); this.assertTrue(label.startsWith("Building Radar tower"), `the work is named: ${label}`); this.assertGreater(env.$("#tile-works .progress").length, 0, "a progress bar is drawn"); screen.leave(); } finally { teardownDom(env); } } async test_double_clicking_a_city_opens_its_panel() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); screen.onState(state.snapshot(0)); const marker = env.$(`#layer-entities .city[data-city-id='${capital.id}'] .marker`).get(0); this.assertNotNull(marker, "the capital is drawn"); // One click selects the city and shows its quick panel. env.$(marker).trigger("click"); this.assertFalse(env.$("#city-info").hasClass("hidden"), "a click opens the quick panel"); this.assertTrue(env.$("#modal-city").hasClass("hidden"), "a click does not open the modal"); // The second click opens the full city panel. marker.dispatchEvent( new env.window.MouseEvent("dblclick", { bubbles: true, cancelable: true }) ); this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city panel opens"); this.assertTrue( env.$("#city-title").text().startsWith(capital.name), "the panel is for the clicked city" ); screen.leave(); } finally { teardownDom(env); } } async test_clicking_a_unit_also_opens_its_tile_panel() { 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.map.onUnitSelected(unit.id); this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens"); this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens too"); screen.leave(); } finally { teardownDom(env); } } async test_stack_panel_card_toggle_narrows_the_selection() { 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._selectUnits([unit.id, second.id]); const $cards = env.$("#unit-stack-list .stack-unit-tile"); this.assertSize($cards, 2, "each stacked unit is a card"); $cards.eq(0).trigger("click"); this.assertEqual(screen.selectedUnitIds.length, 1, "toggling a card off narrows the selection"); this.assertEqual(screen.selectedUnitIds[0], second.id); screen.leave(); } finally { teardownDom(env); } } async test_stack_panel_groups_units_by_type() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const infantry = state.units.find((u) => u.civ === 0); const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]); const artilleryProto = state.protoUnits.find((proto) => proto.id === "artillery"); const artillery = state._spawnUnit(infantry.coords, 0, artilleryProto); screen.onState(state.snapshot(0)); screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]); const $rows = env.$("#unit-stack-list .stack-type-row"); this.assertSize($rows, 2, "one line per unit type"); this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "each unit is a card"); this.assertEqual($rows.eq(0).find(".stack-type-count").text(), "×2", "the infantry line counts two"); this.assertSize($rows.eq(0).find(".stack-unit-tile"), 2, "both infantry cards sit on that line"); this.assertSize( env.$("#unit-stack-list .stack-type-name"), 0, "the type name lives in the icon tooltip, not the line" ); screen.leave(); } finally { teardownDom(env); } } async test_group_checkbox_selects_and_excludes_a_type() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const infantry = state.units.find((u) => u.civ === 0); const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]); const artillery = state._spawnUnit( infantry.coords, 0, state.protoUnits.find((proto) => proto.id === "artillery") ); screen.onState(state.snapshot(0)); screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]); const row = (index) => env.$("#unit-stack-list .stack-type-row").eq(index); this.assertEqual(row(0).find(".stack-group-check").text(), "✓", "the infantry group reads all-ticked"); row(0).find(".stack-group-check").trigger("click"); this.assertEqual(screen.selectedUnitIds.length, 1, "excluding a type leaves the other selected"); this.assertEqual(screen.selectedUnitIds[0], artillery.id); this.assertEqual(row(0).find(".stack-group-check").text(), "", "the excluded group is unticked"); this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "its cards stay in the list"); row(0).find(".stack-group-check").trigger("click"); this.assertEqual(screen.selectedUnitIds.length, 3, "re-ticking the group selects every unit"); this.assertEqual(row(0).find(".stack-group-check").text(), "✓"); screen.leave(); } finally { teardownDom(env); } } async test_an_unticked_card_stays_and_sorts_to_the_back() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const first = state.units.find((u) => u.civ === 0); const second = state._spawnUnit(first.coords, 0, state.protoUnits[0]); const third = state._spawnUnit(first.coords, 0, state.protoUnits[0]); screen.onState(state.snapshot(0)); screen._selectUnits([first.id, second.id, third.id]); let $cards = env.$("#unit-stack-list .stack-unit-tile"); this.assertSize($cards, 3, "all three infantry are cards"); const untickedId = Number($cards.eq(0).attr("data-id")); $cards.eq(0).trigger("click"); $cards = env.$("#unit-stack-list .stack-unit-tile"); this.assertSize($cards, 3, "the unticked card is not dropped"); this.assertEqual( Number($cards.eq(2).attr("data-id")), untickedId, "the unticked card moves to the back of its list" ); this.assertTrue( screen.selectedUnitIds.indexOf(untickedId) === -1, "the unticked unit leaves the selection" ); screen.leave(); } finally { teardownDom(env); } } async test_choosing_a_target_fires_and_an_empty_tile_cancels() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), 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((unit) => unit.id === "artillery") ); const goal = adjacentLand(state, artillery); state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); let fired = null; screen._orderMove = (units, coords, type) => { fired = { units, coords, type }; }; screen._selectUnits([artillery.id]); screen._toggleTargeting(); screen._chooseTarget({ x: goal.x, y: goal.y }); this.assertNull(screen._targeting, "picking a target leaves target mode"); this.assertNotNull(fired, "the strike is issued"); this.assertEqual(fired.type, "attack"); fired = null; screen._toggleTargeting(); screen._chooseTarget({ x: home.coords.x, y: home.coords.y }); this.assertNull(screen._targeting, "a friendly tile still leaves target mode"); this.assertNull(fired, "but no order is sent"); screen.leave(); } finally { teardownDom(env); } } async test_attack_action_enters_targeting_for_a_ranged_stack() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), 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((unit) => unit.id === "artillery") ); const goal = adjacentLand(state, artillery); state._spawnUnit(goal, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); screen._selectUnits([artillery.id]); this.assertFalse( env.$("#unit-attack-order").hasClass("hidden"), "a single ranged type offers the Attack action" ); env.$("#unit-attack-order").trigger("click"); this.assertTrue(env.$("#unit-attack-order").hasClass("active"), "picking a target lights the button"); this.assertNotNull(screen.map._targeting, "the map entered target mode"); this.assertEqual(screen.map._targeting.origin.x, artillery.coords.x, "the reach starts at the battery"); this.assertEqual(screen.map._targeting.origin.y, artillery.coords.y); const svg = screen.map.$targeting.find("svg.targeting-arcs"); this.assertEqual(svg.length, 1, "the reach overlay is the styled targeting-arcs svg"); const boundary = svg.find("path.range-boundary"); this.assertEqual(boundary.length, 1, "the reach boundary is drawn"); this.assertTrue((boundary.attr("d") || "").length > 0, "the boundary is a solid red path, not empty"); screen.leave(); } finally { teardownDom(env); } } async test_a_missile_can_be_armed_with_a_nuclear_warhead() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const missileProto = state.protoUnits.find((p) => p.id === "missile"); const missile = state._spawnUnit(capital.coords, 0, missileProto); const targetCoords = landAtDistance(state, capital.coords, 1); state._spawnUnit(targetCoords, 1, state.protoUnits[0]); screen.onState(state.snapshot(0)); const data = screen._unit(missile.id); screen._selectUnits([missile.id]); this.assertTrue(env.$("#unit-nuclear").hasClass("hidden"), "no warhead without the bomb"); // Research nuclear weapons and take a fresh snapshot. const techIndex = state.technologies.findIndex((t) => t.id === "nuclear_weapons"); state.researched.get(0).add(techIndex); screen.onState(state.snapshot(0)); this.assertFalse(env.$("#unit-nuclear").hasClass("hidden"), "the warhead toggle shows"); env.$("#unit-nuclear").click(); this.assertTrue(screen._nuclearPayload, "the toggle arms the next strike"); screen._sendMove([data], targetCoords, null, "attack", null); const order = screen.network.orders.at(-1); this.assertEqual(order.type, "attack"); this.assertTrue(order.nuclear, "the attack carries the nuclear payload"); screen.leave(); } finally { teardownDom(env); } } async test_city_tile_panel_shows_current_production() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); grantBuilding(state, capital, "barracks"); state.requestTrain(capital.id, 0); state.training.get(capital.id)[0].elapsedHours = state.training.get(capital.id)[0].totalHours / 2; screen.onState(state.snapshot(0)); screen.map.onCitySelected(capital.id); this.assertTrue( env.$("#city-production").text().includes("Training"), "the city panel shows what is in production" ); this.assertTrue( parseFloat(env.$("#city-production .city-production-bar > div").css("width")) > 0, "the quick view has a progress bar" ); screen.leave(); } finally { teardownDom(env); } } async test_stat_tooltips_show_the_long_run_growth() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const snapshot = state.snapshot(0); snapshot.viewerStats.growth = { sinceStart: true, population: 0.0123, gdp: 0.02, gdpPerCapita: 0.01 }; screen.onState(snapshot); const tip = (id) => env.$(`#${id}`).closest(".stat").attr("data-tooltip"); this.assertEqual(tip("stat-pop"), "since 1 January 2000: +1.23%"); this.assertEqual(tip("stat-gdp"), "since 1 January 2000: +2.00%"); this.assertEqual(tip("stat-gdppc"), "since 1 January 2000: +1.00%"); // A year on, the wording and the baseline both change. snapshot.viewerStats.growth = { sinceStart: false, population: -0.005, gdp: 0.0, gdpPerCapita: 0.004 }; screen.onState(snapshot); this.assertEqual(tip("stat-pop"), "year on year: −0.50%"); this.assertEqual(tip("stat-gdp"), "year on year: +0.00%"); // A snapshot without a figure leaves no stale tooltip behind. snapshot.viewerStats.growth = null; screen.onState(snapshot); this.assertNull(tip("stat-pop"), "no growth means no tooltip"); screen.leave(); } finally { teardownDom(env); } } async test_every_summary_stat_uses_the_same_custom_tooltip() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); const state = smallState(); const snapshot = state.snapshot(0); screen.onState(snapshot); const stats = snapshot.viewerStats; // The budget forecasts the next calendar month at the current hourly net. const expected = stats.breakdown.net * hoursInNextMonth(snapshot.totalHours); const sign = expected < 0 ? "−" : "+"; this.assertEqual( env.$("#stat-budget").closest(".stat").attr("data-tooltip"), `${sign}£${compact(Math.abs(expected))}/mo` ); // Popularity speaks through the same bubble, not a native title. const $approval = env.$("#stat-approval").closest(".stat"); this.assertNotNull($approval.attr("data-tooltip"), "popularity has a custom tooltip"); this.assertNull($approval.attr("title"), "popularity has no native title"); screen.leave(); } finally { teardownDom(env); } } async test_the_tile_panel_lists_produced_resources() { 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._showTile({ x: capital.coords.x, y: capital.coords.y }); const $food = env.$("#tile-production .tile-prod-row").filter((_, el) => env.$(el).text().includes("Food") ); this.assertEqual($food.length, 1, "the capital grows food"); this.assertTrue($food.text().includes("/day"), "its output is per day"); // The modifiers that shape the output ride along in the row's tooltip. this.assertNotNull($food.attr("title"), "the row explains its modifiers"); this.assertTrue($food.attr("title").includes("Capital"), `cites the capital bonus: ${$food.attr("title")}`); screen.leave(); } finally { teardownDom(env); } } async test_air_strike_burst_fires_on_the_bomb_run_not_the_flight() { const env = await setupDom(); try { const screen = new GameScreen(stubNetwork(), makeConfig()); screen.enter(); screen.protoUnits = PROTO_UNITS; screen.map.tileInView = () => true; const airIndex = PROTO_UNITS.findIndex((proto) => proto.air); const groundIndex = PROTO_UNITS.findIndex((proto) => proto.military && !proto.air); const triggers = []; screen.airBombing.trigger = (volume) => triggers.push(volume); // The first snapshot only primes the baseline: no burst while the plane // is still flying towards its target. screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] }); this.assertEmpty(triggers, "nothing while the plane is en route"); screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] }); this.assertEmpty(triggers, "still nothing while it flies"); // Reaching the target clears the run: the bombs fall. screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: null }] }); this.assertSize(triggers, 1, "the resolved bomb run triggers the burst"); // A plane shot down before bombing is gone, not detonated. screen._detectAirStrikes({ units: [{ id: 2, proto: airIndex, strikeTarget: [6, 6] }] }); screen._detectAirStrikes({ units: [] }); this.assertSize(triggers, 1, "a downed plane does not bomb"); // An off-screen bomb run is silent. screen.map.tileInView = () => false; screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: [7, 7] }] }); screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: null }] }); this.assertSize(triggers, 1, "the burst only plays in view"); // Aircraft do not drive the ambient barrage, ground batteries still do. screen.map.tileInView = () => true; screen.snapshot = { units: [{ id: 4, proto: airIndex, strikeTarget: [1, 1] }] }; this.assertFalse(screen._bombingInView(), "aircraft stay out of the ambience"); screen.snapshot = { units: [{ id: 5, proto: groundIndex, strikeTarget: [1, 1] }] }; this.assertTrue(screen._bombingInView(), "a ground battery still rumbles"); 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; }