Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight.
672 lines
29 KiB
JavaScript
672 lines
29 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, settleInlandCity } 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 settleInlandCity(state, civ);
|
|
}
|
|
|
|
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);
|
|
// Construction is settled in materials, so stock the city before ordering.
|
|
state.getCityResourceStock(city).steel = 5_000_000;
|
|
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 barracks = buildingIndex(state, "barracks");
|
|
giveBuilding(state, city, "barracks", 3);
|
|
this.assertTrue(state._razeCityBuildings(city, 2500), "a strong enough blow razes");
|
|
this.assertEqual(state.getCityBuildingLevel(city, barracks), 2, "the level drops by one");
|
|
}
|
|
|
|
test_shelters_resist_destruction() {
|
|
const state = smallState();
|
|
const city = state.cities[0];
|
|
const barracks = buildingIndex(state, "barracks");
|
|
const shelters = buildingIndex(state, "shelters");
|
|
giveBuilding(state, city, "barracks", 50);
|
|
giveBuilding(state, city, "shelters", 50);
|
|
for (let i = 0; i < 50; i++) state._razeCityBuildings(city, 2500);
|
|
const ordinaryLost = 50 - state.getCityBuildingLevel(city, barracks);
|
|
const shelterLost = 50 - state.getCityBuildingLevel(city, shelters);
|
|
this.assertEqual(ordinaryLost, 50, "an ordinary building is razed every time");
|
|
this.assertLess(shelterLost, ordinaryLost, "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");
|
|
}
|
|
}
|