Files
Battle-for-Tismo/tests/air_test.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
2026-09-22 14:34:14 +02:00

670 lines
29 KiB
JavaScript

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");
}
}