- aircraft attack orders fly a sortie to the target, bomb once, return to a friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing is bounded by fuel rather than the 10-tile combat radius - artillery and warships open a sustained hourly bombardment on a standing target, drawn as a red dashed line and ring while either endpoint is selected - a game launched from localhost can enable a testing mode whose train and build rows offer free, instant orders; the server only honours them for a loopback admin in a testing game - an open city panel now unlocks aircraft as soon as an airport finishes
368 lines
16 KiB
JavaScript
368 lines
16 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState } from "./framework/helpers.js";
|
|
import { parseKey } from "../shared/hex.js";
|
|
import { AIR_STRIKE } 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 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);
|
|
this.assertFalse(state.requestBuild(city.id, shipyard), "no port, no shipyard");
|
|
giveBuilding(state, city, "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"));
|
|
|
|
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");
|
|
this.assertApprox(
|
|
state.getBudget(0),
|
|
BIG_BUDGET - AIR_STRIKE.cost,
|
|
1e-6,
|
|
"the strike was paid for 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_a_city_ten_tiles_away() {
|
|
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.assertEqual(
|
|
state.topology.tileDistance(home.coords, away.coords),
|
|
10,
|
|
"the fixture's two cities sit ten tiles apart"
|
|
);
|
|
const jet = protoUnit(state, "jet_fighter");
|
|
this.assertGreaterOrEqual(jet.missionRange, 10, "the jet reaches at least ten tiles");
|
|
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
|
|
this.assertTrue(
|
|
state.requestGroupMove([unit.id], away.coords),
|
|
"the ten-tile rebase is accepted"
|
|
);
|
|
state.advanceHour();
|
|
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_their_strike_radius() {
|
|
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");
|
|
this.assertGreater(
|
|
bomber.enduranceHours * bomber.speed,
|
|
bomber.missionRange,
|
|
"fuel lets the bomber ferry beyond its strike radius"
|
|
);
|
|
const unit = state._spawnUnit(home.coords, 0, bomber, home.id);
|
|
this.assertFalse(
|
|
state._strikeReady(unit, away.coords),
|
|
"the ten-tile city is beyond the bomber's strike radius"
|
|
);
|
|
this.assertTrue(
|
|
state.requestGroupMove([unit.id], away.coords),
|
|
"but it can still fly there to rebase"
|
|
);
|
|
}
|
|
|
|
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 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"
|
|
);
|
|
}
|
|
|
|
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 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");
|
|
}
|
|
|
|
test_bombers_fly_to_the_target_and_never_join_the_battle() {
|
|
const state = smallState();
|
|
const bomber = protoUnit(state, "bomber");
|
|
const infantry = protoUnit(state, "modern_infantry");
|
|
const pair = freeAdjacentLand(state);
|
|
const attacker = state._spawnUnit(pair[0], 0, bomber, null);
|
|
const defender = state._spawnUnit(pair[1], 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, pair[1].x, "the bomber flew onto the target");
|
|
this.assertEqual(attacker.coords.y, pair[1].y);
|
|
this.assertEmpty(state._battleTiles(), "no melee battle was started");
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|