import { TestCase } from "./framework/test_case.js"; import { smallState } from "./framework/helpers.js"; import { parseKey, key } from "../shared/hex.js"; import { RESOURCE_RULES } from "../shared/data.js"; const BIG_BUDGET = 1.0e15; function buildingIndex(state, id) { return state.protoBuildings.findIndex((building) => building.id === id); } function protoIndex(state, id) { return state.protoUnits.findIndex((unit) => unit.id === id); } function protoUnit(state, id) { return state.protoUnits.find((unit) => unit.id === id); } function coastalCity(state, civ) { return state.cities.find((city) => city.civ === civ && state.isCoastalCity(city)) || null; } function inlandCity(state, civ) { return state.cities.find((city) => city.civ === civ && !state.isCoastalCity(city)) || null; } function giveBuilding(state, city, id, level = 1) { const index = buildingIndex(state, id); city.buildings[index] = level; state._gdpPerCapitaCache.clear(); } function freeAdjacentLand(state) { for (const cell of state.landCells) { if (state.cityAt(cell) || state.unitAt(cell)) continue; for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { if (!state._isLand(neighbour) || state.cityAt(neighbour) || state.unitAt(neighbour)) continue; return [cell, neighbour]; } } return null; } function freeLandNear(state, center, maxDistance) { let best = null; let bestDistance = Infinity; for (const k in state.tiles) { const coords = parseKey(k); if (state.tiles[k].terrainClass !== "Land") continue; if (state.cityAt(coords) || state.unitAt(coords)) continue; const distance = state.topology.tileDistance(center, coords); if (distance <= 0 || distance > maxDistance) continue; if (distance < bestDistance) { bestDistance = distance; best = coords; } } return best; } function landAtDistance(state, center, distance) { for (const k in state.tiles) { const coords = parseKey(k); 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; } function farTile(state, from, minDistance) { for (const k in state.tiles) { const coords = parseKey(k); if (state.topology.tileDistance(from, coords) <= minDistance) continue; if (state.cityAt(coords) || state.unitAt(coords)) continue; return coords; } return null; } export class AirTest extends TestCase { test_port_can_only_be_raised_in_a_coastal_city() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const port = buildingIndex(state, "port"); const inland = inlandCity(state, 0); const coastal = coastalCity(state, 0); this.assertNotNull(inland, "the fixture has an inland city"); this.assertNotNull(coastal, "the fixture has a coastal city"); this.assertFalse(state.requestBuild(inland.id, port), "an inland city cannot build a port"); this.assertTrue(state.requestBuild(coastal.id, port), "a coastal city can build a port"); } test_shipyard_needs_a_coastal_city_and_a_port() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const shipyard = buildingIndex(state, "shipyard"); const city = coastalCity(state, 0); // Every naval city opens the game with a port. this.assertTrue(state.hasCityBuilding(city, "port"), "a naval city starts with a port"); this.assertTrue(state.requestBuild(city.id, shipyard), "a port unlocks the shipyard"); // Even with a port, an inland city has no water to launch from. const inland = inlandCity(state, 0); this.assertNotNull(inland, "the fixture has an inland city"); giveBuilding(state, inland, "port"); this.assertFalse(state.requestBuild(inland.id, shipyard), "a shipyard is coastal-only"); } test_airport_and_shipyard_gate_training() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const city = coastalCity(state, 0); const jet = protoIndex(state, "jet_fighter"); const battleship = protoIndex(state, "battleship"); this.assertFalse(state.requestTrain(city.id, jet), "no airport, no aircraft"); this.assertFalse(state.requestTrain(city.id, battleship), "no shipyard, no warship"); giveBuilding(state, city, "airport"); giveBuilding(state, city, "shipyard"); this.assertTrue(state.requestTrain(city.id, jet), "the airport unlocks aircraft"); this.assertTrue(state.requestTrain(city.id, battleship), "the shipyard unlocks warships"); } test_trained_aircraft_gets_its_home_airport() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); const jet = protoIndex(state, "jet_fighter"); this.assertTrue(state.requestTrain(city.id, jet), "the airport lets the order through"); for (let i = 0; i < 100000 && state.training.size > 0; i++) state.advanceHour(); const unit = state.units.find((u) => u.proto === jet && u.civ === 0); this.assertNotNull(unit, "the aircraft was produced"); this.assertEqual(unit.homeCityId, city.id, "it is based at the training city"); this.assertFalse(unit.airborne, "it is parked on the runway"); } test_battleships_launch_onto_an_adjacent_sea_tile() { const state = smallState(); const city = coastalCity(state, 0); const battleship = protoUnit(state, "battleship"); const unit = state._spawnTrainedUnit(city, battleship); const tile = state.tiles[`${unit.coords.x},${unit.coords.y}`]; this.assertEqual(tile.terrainClass, "Sea", "the hull is launched on water"); this.assertTrue( state.topology.tileDistance(unit.coords, city.coords) === 1, "the launch tile neighbours the city" ); } test_aircraft_strike_sortie_bombs_returns_and_rearms() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const jet = protoUnit(state, "jet_fighter"); const unit = state._spawnUnit(city.coords, 0, jet, city.id); const target = freeLandNear(state, city.coords, jet.missionRange); this.assertNotNull(target, "there is a target tile within range"); const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry")); const totalTech = () => state.cities.filter((c) => c.civ === 0) .reduce((sum, c) => sum + (state.getCityResourceStock(c).hightech || 0), 0); const beforeTech = totalTech(); this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the strike is accepted"); this.assertEqual(unit.coords.x, city.coords.x, "it has not taken off yet"); // A sortie is paid for in munitions, not money. this.assertApprox( beforeTech - totalTech(), RESOURCE_RULES.combatConsumption.airStrike.hightech, 1e-6, "the sortie spent its high-tech up front" ); state.advanceHour(); this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target"); this.assertNull(unit.strikeTarget, "the bomb run is finished"); state.advanceHour(); this.assertEqual(unit.coords.x, city.coords.x, "it returned to its airport"); this.assertEqual(unit.coords.y, city.coords.y); this.assertFalse(unit.airborne, "it is parked again"); this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled"); this.assertGreater(unit.strikeReadyHour, state.totalHours, "its cooldown begins on landing"); } test_aircraft_cannot_strike_again_while_rearming() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const jet = protoUnit(state, "jet_fighter"); const unit = state._spawnUnit(city.coords, 0, jet, city.id); const target = freeLandNear(state, city.coords, jet.missionRange); const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry")); this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the first strike flies"); state.advanceHour(); state.advanceHour(); const second = freeLandNear(state, city.coords, jet.missionRange); this.assertNotNull(second, "there is another target in range"); const newDefender = state._spawnUnit(second, 1, protoUnit(state, "modern_infantry")); this.assertFalse( state.requestAirStrike([unit.id], newDefender.coords), "a re-arming aircraft is grounded" ); } test_aircraft_crash_when_no_airport_can_reach_them() { const state = smallState(); const jet = protoUnit(state, "jet_fighter"); const spot = freeAdjacentLand(state); const unit = state._spawnUnit(spot[0], 0, jet, null); this.assertNull(state._nearestFriendlyAirport(unit), "no airport exists yet"); for (let hour = 1; hour < jet.enduranceHours; hour++) state.advanceHour(); this.assertNotNull(state.findUnit(unit.id), "still flying before the tanks run dry"); state.advanceHour(); this.assertNull(state.findUnit(unit.id), "out of fuel, it crashed"); } test_aircraft_cross_every_terrain_at_their_listed_speed() { const state = smallState(); const jet = protoUnit(state, "jet_fighter"); const unit = state._spawnUnit(state.landCells[0], 0, jet, null); const expected = 1 / jet.speed; let checked = 0; for (const k in state.tiles) { this.assertApprox( state._tileTravelHours(unit, parseKey(k)), expected, 1e-9, `flat flight time over ${state.tiles[k].terrainType}` ); checked += 1; if (checked >= 12) break; } this.assertGreater(checked, 0); } test_jet_fighter_can_rebase_to_another_city() { const state = smallState(); const airport = buildingIndex(state, "airport"); const cities = state.cities.filter((c) => c.civ === 0); for (const city of cities) city.buildings[airport] = 1; const [home, away] = cities; this.assertGreater( state.topology.tileDistance(home.coords, away.coords), 1, "the fixture's two cities are apart" ); const jet = protoUnit(state, "jet_fighter"); const unit = state._spawnUnit(home.coords, 0, jet, home.id); unit.airHours = jet.enduranceHours; this.assertTrue( state.requestGroupMove([unit.id], away.coords), "the rebase to a friendly city is accepted" ); for (let hour = 0; hour < 60 && unit.coords.x !== away.coords.x; hour++) { state.advanceHour(); } this.assertEqual(unit.coords.x, away.coords.x, "it reached the far city"); this.assertEqual(unit.coords.y, away.coords.y); this.assertFalse(unit.airborne, "it landed and refuelled"); this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled"); } test_aircraft_can_ferry_farther_than_they_can_strike() { const state = smallState(); const airport = buildingIndex(state, "airport"); const cities = state.cities.filter((c) => c.civ === 0); for (const city of cities) city.buildings[airport] = 1; const [home, away] = cities; const bomber = protoUnit(state, "bomber"); const unit = state._spawnUnit(home.coords, 0, bomber, home.id); // Pin the fuel to exactly the one-way trip to the far city. A target just // beside that city still has to be left and returned from, so it is beyond // the tanks even though the city itself can be ferried to. unit.airHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords)); const beyond = state.topology .neighbours(away.coords.x, away.coords.y) .filter((n) => state._isLand(n) && !state.cityAt(n)) // The far side of the city, so reaching it and returning to the city // costs more than the one-way ferry. .sort((a, b) => state.topology.tileDistance(home.coords, b) - state.topology.tileDistance(home.coords, a) )[0]; this.assertNotNull(beyond, "a land tile beyond the far city"); this.assertFalse( state._strikeReady(unit, beyond), "a target beside the far city is beyond the bomber's strike reach" ); this.assertTrue( state.requestGroupMove([unit.id], away.coords), "but the city itself can still be ferried to" ); } test_an_aircraft_returns_to_the_nearest_friendly_airport_not_its_home() { const state = smallState(); const airport = buildingIndex(state, "airport"); const cities = state.cities.filter((c) => c.civ === 0); for (const city of cities) city.buildings[airport] = 1; const [home, away] = cities; const jet = protoUnit(state, "jet_fighter"); const unit = state._spawnUnit(home.coords, 0, jet, home.id); const beside = state.topology .neighbours(away.coords.x, away.coords.y) .find((n) => state._isLand(n)); this.assertNotNull(beside, "a land tile beside the far city"); const airportCity = state._returnAirport(unit, beside); this.assertNotNull(airportCity, "there is a return airport"); this.assertEqual(airportCity.id, away.id, "the nearest airport, not the home base"); } test_air_strike_is_limited_to_the_mission_radius() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const jet = protoUnit(state, "jet_fighter"); const saved = jet.missionRange; // The fixture map is tiny, so shrink the radius to fit it. jet.missionRange = 2; try { const unit = state._spawnUnit(city.coords, 0, jet, city.id); const outside = farTile(state, city.coords, jet.missionRange); this.assertNotNull(outside, "the map has tiles beyond the radius"); const far = state._spawnUnit(outside, 1, protoUnit(state, "modern_infantry")); this.assertFalse( state.requestAirStrike([unit.id], far.coords), "a target beyond the radius is refused" ); const inside = freeLandNear(state, city.coords, jet.missionRange); this.assertNotNull(inside, "there is a target inside the radius"); const near = state._spawnUnit(inside, 1, protoUnit(state, "modern_infantry")); this.assertTrue( state.requestAirStrike([unit.id], near.coords), "a target inside the radius is struck" ); } finally { jet.missionRange = saved; } } test_aircraft_cannot_be_ordered_beyond_their_range() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); const jet = protoUnit(state, "jet_fighter"); const saved = jet.missionRange; jet.missionRange = 2; try { const unit = state._spawnUnit(city.coords, 0, jet, city.id); const target = farTile(state, city.coords, jet.missionRange); this.assertNotNull(target, "the map has tiles beyond the radius"); this.assertFalse(state.requestGroupMove([unit.id], target), "the order is refused"); this.assertEmpty(unit.path, "no route was stored"); } finally { jet.missionRange = saved; } } test_air_strike_beyond_the_round_trip_fuel_is_refused() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const bomber = protoUnit(state, "bomber"); const unit = state._spawnUnit(city.coords, 0, bomber, city.id); // Pin the fuel: six tiles out is within the one-way range (ten) and the // mission radius, but six back makes twelve, beyond two hours in the tanks. unit.airHours = 2; const goal = landAtDistance(state, city.coords, 6); this.assertNotNull(goal, "the map has a tile six tiles away"); this.assertFalse( state._strikeReady(unit, goal), "a target the aircraft could not strike and leave is refused" ); } test_bomber_strikes_at_the_edge_of_its_round_trip() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const bomber = protoUnit(state, "bomber"); const unit = state._spawnUnit(city.coords, 0, bomber, city.id); // Pin the fuel so five tiles is exactly the edge of the round trip. unit.airHours = 2; const goal = landAtDistance(state, city.coords, 5); this.assertNotNull(goal, "the map has a tile five tiles away"); const defender = state._spawnUnit(goal, 1, protoUnit(state, "modern_infantry")); this.assertTrue(state.requestAirStrike([unit.id], goal), "the edge-of-fuel strike is accepted"); state.advanceHour(); this.assertLess(defender.hp, defender.maxHp, "the bomb run reached the target"); state.advanceHour(); this.assertEqual(unit.coords.x, city.coords.x, "and the bomber came home"); this.assertEqual(unit.coords.y, city.coords.y); } test_bomber_strikes_a_garrisoned_enemy_city() { const state = smallState(); const own = coastalCity(state, 0); giveBuilding(state, own, "airport"); state.budgets.set(0, BIG_BUDGET); const bomber = protoUnit(state, "bomber"); const unit = state._spawnUnit(own.coords, 0, bomber, own.id); // The scenario garrisons every capital, so the enemy capital is the city // that is certain to hold a defender. const enemyCity = state.cities.find((c) => c.civ === 1 && c.isCapital); this.assertNotNull(enemyCity, "the fixture has a garrisoned enemy capital"); // Give the plane the reach so the city tile itself is the only obstacle. unit.airHours = 100; const savedRange = bomber.missionRange; bomber.missionRange = 100; try { // The capital is garrisoned, so the city tile holds a military unit that // the strike must be allowed to bomb. const defender = state.units.find( (u) => u.civ === 1 && u.coords.x === enemyCity.coords.x && u.coords.y === enemyCity.coords.y ); this.assertNotNull(defender, "the enemy city has a garrison"); this.assertTrue( state.requestAirStrike([unit.id], enemyCity.coords), "a garrisoned city can be struck" ); for (let hour = 0; hour < 200 && defender.hp === defender.maxHp; hour++) { state.advanceHour(); } this.assertLess(defender.hp, defender.maxHp, "the bomb hit the garrison"); this.assertEqual(enemyCity.civ, 1, "the bomber did not capture the city"); } finally { bomber.missionRange = savedRange; } } test_bombers_fly_to_the_target_and_never_join_the_battle() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const bomber = protoUnit(state, "bomber"); const infantry = protoUnit(state, "modern_infantry"); const attacker = state._spawnUnit(city.coords, 0, bomber, city.id); const goal = freeLandNear(state, city.coords, 1); this.assertNotNull(goal, "there is a target tile beside the airport"); const defender = state._spawnUnit(goal, 1, infantry); this.assertTrue(state.requestAirStrike([attacker.id], defender.coords), "the strike is accepted"); state.advanceHour(); this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target"); this.assertEqual(attacker.coords.x, goal.x, "the bomber flew onto the target"); this.assertEqual(attacker.coords.y, goal.y); this.assertEmpty(state._battleTiles(), "no melee battle was started"); } test_a_bomber_that_bombs_between_ticks_still_returns_home() { const state = smallState(); const city = coastalCity(state, 0); giveBuilding(state, city, "airport"); state.budgets.set(0, BIG_BUDGET); const bomber = protoUnit(state, "bomber"); const unit = state._spawnUnit(city.coords, 0, bomber, city.id); const goal = landAtDistance(state, city.coords, 5); this.assertNotNull(goal, "the map has a tile five tiles away"); const defender = state._spawnUnit(goal, 1, protoUnit(state, "modern_infantry")); this.assertTrue(state.requestAirStrike([unit.id], goal), "the strike is accepted"); // The live server advances movement every frame and strikes whole hours, so // the bomb run can finish just after an hourly tick. The old code let the // plane idle there until the next tick, then crashed it for an empty tank // before it could turn for home. state.advanceMovement(0.6); state.tickHour(); state.advanceMovement(0.5); this.assertLess(defender.hp, defender.maxHp, "the bomb run reached the target"); state.tickHour(); this.assertNotNull(state.findUnit(unit.id), "the bomber survived the idle hour"); state.advanceMovement(1.0); state.tickHour(); this.assertNotNull(state.findUnit(unit.id), "the bomber was not crashed on the way home"); this.assertEqual(unit.coords.x, city.coords.x, "it made it back to its runway"); this.assertEqual(unit.coords.y, city.coords.y); this.assertFalse(unit.airborne, "and it is parked again"); } test_ground_ranged_units_bombard_every_hour() { const state = smallState(); const artillery = protoUnit(state, "artillery"); const infantry = protoUnit(state, "modern_infantry"); const pair = freeAdjacentLand(state); const attacker = state._spawnUnit(pair[0], 0, artillery); const defender = state._spawnUnit(pair[1], 1, infantry); this.assertTrue(state._isStrikeUnit(attacker), "artillery counts as a ranged attacker"); this.assertTrue(state.requestBombard([attacker.id], defender.coords), "the battery opens fire"); this.assertEqual(defender.hp, defender.maxHp, "the first volley waits for the hour"); this.assertEqual(attacker.coords.x, pair[0].x, "the artillery never moved"); this.assertEqual(attacker.coords.y, pair[0].y); state.advanceHour(); this.assertLess(defender.hp, defender.maxHp, "the hourly shell damaged the target"); this.assertEmpty(state._battleTiles(), "no melee battle was started"); } test_ground_bombard_beyond_reach_is_refused() { const state = smallState(); const artillery = protoUnit(state, "artillery"); const infantry = protoUnit(state, "modern_infantry"); const from = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const attacker = state._spawnUnit(from, 0, artillery); const goal = farTile(state, from, artillery.range); this.assertNotNull(goal, "the map has a tile beyond the battery's range"); const defender = state._spawnUnit(goal, 1, infantry); this.assertFalse(state.requestBombard([attacker.id], goal), "a target out of reach is refused"); this.assertEqual(defender.hp, defender.maxHp, "the target is untouched"); } test_only_air_or_long_reach_units_are_ranged() { const state = smallState(); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); const infantry = state._spawnUnit(spot, 0, protoUnit(state, "modern_infantry"), null); const worker = state._spawnUnit(spot, 0, protoUnit(state, "worker"), null); const artillery = state._spawnUnit(spot, 0, protoUnit(state, "artillery"), null); const jet = state._spawnUnit(spot, 0, protoUnit(state, "jet_fighter"), null); this.assertFalse(state._isStrikeUnit(infantry), "infantry is melee"); this.assertFalse(state._isStrikeUnit(worker), "a worker is not a ranged attacker"); this.assertTrue(state._isStrikeUnit(artillery), "artillery outranges one tile"); this.assertTrue(state._isStrikeUnit(jet), "an aircraft attacks from the air"); } } // A sea tile, unoccupied by hulls, from which a battleship can shell the shore. function freeSeaNearCoast(state) { for (const k in state.tiles) { const coords = parseKey(k); if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(coords) || state.cityAt(coords)) { continue; } for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { if (!state._isLand(neighbour)) continue; if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; return { sea: coords, land: { x: neighbour.x, y: neighbour.y } }; } } return null; } function freeSeaNear(state, coords, maxDistance) { for (const k in state.tiles) { const cell = parseKey(k); if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(cell) || state.cityAt(cell)) continue; if (state.topology.tileDistance(cell, coords) <= maxDistance) return cell; } return null; } export class CoastalBombardTest extends TestCase { test_a_battleship_bombards_a_coastal_unit() { const state = smallState(); const spot = freeSeaNearCoast(state); this.assertNotNull(spot, "the map has an open coast"); const battleship = state._spawnUnit(spot.sea, 0, protoUnit(state, "battleship")); const defender = state._spawnUnit(spot.land, 1, protoUnit(state, "modern_infantry")); this.assertTrue( state.requestBombard([battleship.id], defender.coords), "the warship opens fire on the shore" ); this.assertEqual(defender.hp, defender.maxHp, "the first salvo waits for the hour"); state.advanceHour(); this.assertLess(defender.hp, defender.maxHp, "the coastal unit is hit"); this.assertEqual(battleship.coords.x, spot.sea.x, "the warship holds station"); this.assertEqual(battleship.coords.y, spot.sea.y); } test_a_battleship_shells_a_defenceless_coastal_city() { const state = smallState(); // The generated map does not promise an enemy coastal city, so move one // onto a free stretch of coast: the test is about the shelling, not where // the city happened to be founded. const city = state.cities.find((c) => c.civ === 1); const spot = freeSeaNearCoast(state); this.assertNotNull(spot, "the map has an open coast"); city.coords = { x: spot.land.x, y: spot.land.y }; state._rebuildCityIndex(); state.tilePopulation.set(key(city.coords.x, city.coords.y), 500000); this.assertTrue(state.isCoastalCity(city), "the relocated enemy city is coastal"); const sea = freeSeaNear(state, city.coords, 2); this.assertNotNull(sea, "there is open water within the guns' reach of the city"); const battleship = state._spawnUnit(sea, 0, protoUnit(state, "battleship")); const k = `${city.coords.x},${city.coords.y}`; const before = state.tilePopulation.get(k) || 0; this.assertTrue(state.requestBombard([battleship.id], city.coords), "the city is a target"); state.advanceHour(); this.assertLess(state.tilePopulation.get(k) || 0, before, "the bombardment killed civilians"); } test_shelters_cut_civilian_bombing_losses() { const exposed = smallState(); const exposedCity = exposed.cities[0]; const exposedKey = key(exposedCity.coords.x, exposedCity.coords.y); const exposedBefore = exposed.tilePopulation.get(exposedKey); exposed._bombardCity(exposedCity, 1); const lostExposed = exposedBefore - exposed.tilePopulation.get(exposedKey); const sheltered = smallState(); const shelteredCity = sheltered.cities[0]; giveBuilding(sheltered, shelteredCity, "shelters", 2); const shelteredKey = key(shelteredCity.coords.x, shelteredCity.coords.y); const shelteredBefore = sheltered.tilePopulation.get(shelteredKey); sheltered._bombardCity(shelteredCity, 1); const lostSheltered = shelteredBefore - sheltered.tilePopulation.get(shelteredKey); this.assertGreater(lostExposed, 0, "the bombs kill people"); this.assertApprox(lostSheltered, lostExposed * 0.8, 1e-6, "two shelter levels save 20%"); } test_ranged_fire_can_destroy_a_building_level() { const state = smallState(); const city = state.cities[0]; const market = buildingIndex(state, "market"); giveBuilding(state, city, "market", 3); this.assertTrue(state._razeCityBuildings(city, 2500), "a strong enough blow razes"); this.assertEqual(state.getCityBuildingLevel(city, market), 2, "the level drops by one"); } test_shelters_resist_destruction() { const state = smallState(); const city = state.cities[0]; const market = buildingIndex(state, "market"); const shelters = buildingIndex(state, "shelters"); giveBuilding(state, city, "market", 50); giveBuilding(state, city, "shelters", 50); for (let i = 0; i < 50; i++) state._razeCityBuildings(city, 2500); const marketLost = 50 - state.getCityBuildingLevel(city, market); const shelterLost = 50 - state.getCityBuildingLevel(city, shelters); this.assertEqual(marketLost, 50, "an ordinary building is razed every time"); this.assertLess(shelterLost, marketLost, "shelters are far harder to destroy"); } test_air_defense_damages_a_hostile_plane_over_the_city() { const state = smallState(); const city = state.cities.find((c) => c.civ === 1); giveBuilding(state, city, "aa_building", 1); const jet = state._spawnUnit(city.coords, 0, protoUnit(state, "jet_fighter")); const before = jet.hp; this.assertFalse(state._airDefenseVolley(jet), "one volley does not down a fighter"); this.assertLess(jet.hp, before, "the plane takes damage"); } test_air_defense_ignores_friendly_aircraft() { const state = smallState(); const city = state.cities.find((c) => c.civ === 0); giveBuilding(state, city, "aa_building", 1); const jet = state._spawnUnit(city.coords, 0, protoUnit(state, "jet_fighter")); const before = jet.hp; this.assertFalse(state._airDefenseVolley(jet)); this.assertEqual(jet.hp, before, "friendly planes are not fired on"); } test_air_defense_scales_with_level_and_can_down_a_plane() { const state = smallState(); const city = state.cities.find((c) => c.civ === 1); giveBuilding(state, city, "aa_building", 3); const jet = state._spawnUnit(city.coords, 0, protoUnit(state, "jet_fighter")); this.assertTrue(state._airDefenseVolley(jet), "a high-level battery downs the plane"); this.assertNull(state.findUnit(jet.id), "the plane is gone"); } }