Fixed air strikes against garrisons and the round-trip fuel model

- a strike is accepted only when the whole round trip (out to the target, then
  home to a friendly airport) fits the remaining fuel, and the browser mirrors
  the check so an unreachable target is refused with its distance and the
  plane's effective range
- a bomber that bombs between hourly ticks turns for home at once instead of
  idling and crashing on an empty tank; a plane with a runway lands on fumes
- aircraft may now target a garrison inside a hostile city: they bomb it and
  turn away, and captures skip aircraft so the city is never taken
- bomber/jet endurance and mission-range tuning
This commit is contained in:
2026-09-18 22:46:19 +02:00
parent 1a9d48cdc9
commit cf75b31e68
9 changed files with 392 additions and 73 deletions
+8 -1
View File
@@ -87,7 +87,14 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`strikeReadyHour` cooldown. The strike is charged up front per aircraft
(`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and
the ferry is bounded by fuel, not the combat `missionRange`; they never join
melee. A standing `strikeTarget` is shipped in the snapshot and
melee. A strike may target a garrison inside a hostile city — the plane enters
that tile as a goal only to bomb and turns for home, and captures skip
aircraft, so it never occupies or takes the city. A strike is only accepted
when the whole round trip fits the remaining
fuel (out to the target, then home to a friendly airport), and the browser
mirrors that check so an unreachable target is refused with a message instead
of an order the server would drop. A standing `strikeTarget` is shipped in the
snapshot and
`map_view/motion.js` draws the red dashed line and target ring on
`#layer-targets` while either endpoint is selected.
- Terrain generation is deterministic from `(seed, mapConfig)`, so
+11 -4
View File
@@ -228,14 +228,21 @@ export class GameScreen {
}
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
// A strike is refused before it is ever sent when the target lies beyond
// the unit's reach, so the browser and the server never disagree.
// the unit's reach, so the browser and the server never disagree. An air
// strike must fit the whole round trip, not just the flight out.
if (airStrike) {
const reason = this._airStrikeRangeError(units, coords);
if (reason) {
this.confirmModal.show("Out of range", reason, null);
return;
}
}
const outOfRange = units.some((unit) => {
const proto = this.protoUnits[unit.proto];
if (!proto) return false;
if (airStrike || striking) {
if (striking) {
const from = { x: unit.coords[0], y: unit.coords[1] };
const reach = airStrike ? (proto.missionRange || Infinity) : (proto.range || 1);
return this.map.topology.tileDistance(from, coords) > reach;
return this.map.topology.tileDistance(from, coords) > (proto.range || 1);
}
const route = routes.get(unit.id);
if (!route) return false;
+60
View File
@@ -299,6 +299,66 @@ export const panelMethods = {
return null;
},
// The friendly airport an aircraft would fly home to from `coords`, mirroring
// GameState._returnAirport: its own base while that is still a friendly
// airport, otherwise the nearest friendly airport anywhere.
_airReturnAirport(unit, coords) {
const cities = (this.snapshot && this.snapshot.cities) || [];
const index = BUILDINGS.findIndex((b) => b.id === "airport");
const hasAirport = (city) =>
index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
const home = cities.find((city) => city.id === unit.homeCityId);
if (home && home.civ === this.localCiv && hasAirport(home)) return home;
let best = null;
let bestDistance = Infinity;
for (const city of cities) {
if (city.civ !== this.localCiv || !hasAirport(city)) continue;
const distance = this.map.topology.tileDistance(coords, {
x: city.coords[0],
y: city.coords[1],
});
if (distance < bestDistance) {
bestDistance = distance;
best = city;
}
}
return best;
},
// Why an air strike on `coords` is out of reach, or null when the whole round
// trip fits. Mirrors GameState._strikeReady so the browser refuses exactly
// what the server would, instead of sending an order that is dropped.
_airStrikeRangeError(units, coords) {
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
if (!proto) return "Unknown aircraft.";
const from = { x: unit.coords[0], y: unit.coords[1] };
const toTarget = this.map.topology.tileDistance(from, coords);
const airport = this._airReturnAirport(unit, coords);
if (!airport) return "This aircraft has no friendly airport to return to.";
const toHome = this.map.topology.tileDistance(coords, {
x: airport.coords[0],
y: airport.coords[1],
});
const speed = proto.speed || 1;
const fuel = unit.airHours === undefined || unit.airHours === null
? proto.enduranceHours
: unit.airHours;
// The furthest it can strike: half the fuel buys the flight out, the other
// half the flight home, capped by the mission radius.
const effectiveRange = Math.floor(
Math.min(proto.missionRange || Infinity, (fuel * speed) / 2 + 1e-9)
);
const beyondMission =
toTarget > (proto.missionRange || Infinity);
const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9;
if (beyondMission || beyondFuel) {
return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`;
}
}
return null;
},
// Whether `coords` is one of our own cities with an airport, the only tiles
// an aircraft may be ordered to when it is not attacking.
_isFriendlyAirport(coords) {
+2 -2
View File
@@ -458,8 +458,8 @@ export const motionMethods = {
}
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== civ) {
// Mirrors GameState._canUnitEnter: aircraft never occupy an enemy city.
if (air) return false;
// Mirrors GameState._canUnitEnter: a hostile city is only a destination,
// open to ground troops capturing it and to aircraft bombing its garrison.
if (!isGoal) return false;
if (proto.military !== true) return false;
}
+4 -4
View File
@@ -72,7 +72,7 @@ export const PROTO_UNITS = [
// an hour of reserve.
air: true,
enduranceHours: 5,
missionRange: 10,
missionRange: 40,
requiresBuilding: "airport",
icon: "icon_jet_fighter.svg",
},
@@ -88,11 +88,11 @@ export const PROTO_UNITS = [
attack: 450,
defense: 10,
// A bomber strikes from `range` tiles away and never joins the melee.
range: 2,
range: 0,
traversableTerrains: ["Land", "Sea", "Ice"],
air: true,
enduranceHours: 2,
missionRange: 5,
enduranceHours: 10,
missionRange: 50,
strike: true,
requiresBuilding: "airport",
icon: "icon_bomber.svg",
+54 -27
View File
@@ -57,16 +57,16 @@ export const airMethods = {
return this.hasCityBuilding(city, "airport") ? city : null;
},
// The airport an aircraft flies home to: its own base when still friendly,
// otherwise the nearest friendly airport anywhere.
_nearestFriendlyAirport(unit) {
// The airport an aircraft flies home to from `from`: its own base when still
// friendly, otherwise the nearest friendly airport anywhere.
_returnAirport(unit, from = unit.coords) {
const home = this.findCity(unit.homeCityId);
if (home && home.civ === unit.civ && this.hasCityBuilding(home, "airport")) return home;
let best = null;
let bestDistance = Infinity;
for (const city of this.cities) {
if (city.civ !== unit.civ || !this.hasCityBuilding(city, "airport")) continue;
const distance = this.topology.tileDistance(unit.coords, city.coords);
const distance = this.topology.tileDistance(from, city.coords);
if (distance < bestDistance) {
bestDistance = distance;
best = city;
@@ -75,6 +75,10 @@ export const airMethods = {
return best;
},
_nearestFriendlyAirport(unit) {
return this._returnAirport(unit, unit.coords);
},
_pathHours(unit, path) {
let total = 0;
for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]);
@@ -120,36 +124,41 @@ export const airMethods = {
unit.airHours = proto.enduranceHours;
unit.airborne = false;
unit.autoReturn = false;
// Any bomb run is over once the plane is back on the tarmac.
unit.strikeTarget = null;
continue;
}
if (unit.airHours === undefined || unit.airHours === null) {
unit.airHours = proto.enduranceHours;
}
unit.airborne = true;
if (unit.strikeTarget) {
// The round trip was validated when the strike was ordered; press on.
} else if (unit.autoReturn) {
// Already heading for a runway.
} else if (this._hasRoute(unit)) {
// Ferrying: turn for home once the tanks can no longer cover both the
// rest of the trip and the flight back to a friendly airport.
const airport = this._nearestFriendlyAirport(unit);
const home = airport ? this.findPath(unit, unit.coords, airport.coords) : [];
if (home.length >= 2 && this._pathHours(unit, home) >= unit.airHours - 1e-9) {
this._orderAirReturn(unit);
}
} else {
this._orderAirReturn(unit);
}
unit.airHours -= 1;
if (unit.airHours <= 0) crashed.push(unit);
// A plane with a runway to reach is never stranded: it lands on fumes.
// Only one that has no route to any airport goes down when the tanks dry.
if (unit.airHours <= 0 && !unit.strikeTarget && !this._hasRoute(unit)) {
crashed.push(unit);
}
}
for (const unit of crashed) {
this._recordMilitaryCasualties([unit]);
this._destroyUnit(unit);
}
if (crashed.length > 0) this._visibilityDirty = true;
for (const unit of this.units) {
const proto = this.unitProto(unit);
if (!this._isAirProto(proto) || !unit.airborne || unit.autoReturn) continue;
if (this._hasRoute(unit)) {
// Turn for home before the tanks run dry.
const airport = this._nearestFriendlyAirport(unit);
if (!airport) continue;
const home = this.findPath(unit, unit.coords, airport.coords);
if (home.length < 2) continue;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, home) >= fuel - 1e-9) this._orderAirReturn(unit);
continue;
}
this._orderAirReturn(unit);
}
},
// ----------------------------------------------------------- strike --
@@ -172,9 +181,27 @@ export const airMethods = {
: (proto.range || 1);
},
// Whether an aircraft has the fuel for the whole sortie: fly to `goal`, bomb,
// then run home to a friendly airport. A strike that could not make it back
// is refused up front, so a sortie never strands a plane. Air routes ignore
// the ground, so tile distance is the number of flight steps.
_canStrikeAndReturn(unit, goal, fuel = null) {
const proto = this.unitProto(unit);
if (!proto) return false;
const airport = this._returnAirport(unit, goal);
if (!airport) return false;
const remaining = fuel === null
? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours)
: fuel;
const tiles =
this.topology.tileDistance(unit.coords, goal) +
this.topology.tileDistance(goal, airport.coords);
return tiles / this._unitSpeed(unit) <= remaining + 1e-9;
},
// Whether a unit may strike `goal` right now: inside its reach and, for an
// aircraft, parked/armed with fuel in the tanks and not still rearming or
// already flying a mission.
// aircraft, parked/armed with fuel for the round trip and not still rearming
// or already flying a mission.
_strikeReady(unit, goal) {
const proto = this.unitProto(unit);
if (!proto) return false;
@@ -184,7 +211,8 @@ export const airMethods = {
if (unit.strikeTarget) return false;
if (this.totalHours < (unit.strikeReadyHour || 0)) return false;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
return fuel > 0;
if (fuel <= 0) return false;
return this._canStrikeAndReturn(unit, goal, fuel);
},
// A ground or naval battery opens a sustained bombardment of `goal`: it never
@@ -220,13 +248,12 @@ export const airMethods = {
if (!target) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
// `_strikeReady` above already proved the round trip fits the fuel; this
// only stores the outbound leg the plane flies first.
const paths = [];
for (const unit of units) {
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
const proto = this.unitProto(unit);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
paths.push(path);
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
+6 -2
View File
@@ -222,6 +222,9 @@ export const movementMethods = {
unit.coords.y === unit.strikeTarget.y) {
this._resolveAirStrike(unit);
this._stopUnit(unit);
// Turn for home at once: idling on the target until the next
// hourly tick would burn fuel the round trip never budgeted for.
this._orderAirReturn(unit);
break;
}
} else if (this._hasHostileOn(unit.coords, unit.civ)) {
@@ -372,8 +375,9 @@ export const movementMethods = {
}
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (city && city.civ !== unit.civ) {
// Aircraft never occupy an enemy city; they attack from the air instead.
if (air) return false;
// A hostile city is only ever a destination: ground troops march in to
// capture it, and aircraft may bomb its garrison. Aircraft never occupy
// it (captures skip them), so the tile stays closed as a through-route.
if (!isGoal) return false;
if (!this._isMilitary(unit)) return false;
}
+168 -33
View File
@@ -59,6 +59,16 @@ function freeLandNear(state, center, maxDistance) {
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);
@@ -249,26 +259,25 @@ export class AirTest extends TestCase {
this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled");
}
test_aircraft_can_ferry_farther_than_their_strike_radius() {
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");
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);
// Pin the fuel so the test does not depend on the catalogue's endurance:
// ten tiles out and ten back is four hours, beyond the two hours in the
// tanks, so it cannot strike that far even though it can fly it.
unit.airHours = 2;
this.assertFalse(
state._strikeReady(unit, away.coords),
"the ten-tile city is beyond the bomber's strike radius"
"the ten-tile city is beyond the bomber's strike reach"
);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"but it can still fly there to rebase"
"but it can still ferry there to rebase"
);
}
@@ -278,21 +287,28 @@ export class AirTest extends TestCase {
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"
);
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() {
@@ -300,28 +316,147 @@ export class AirTest extends TestCase {
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");
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);
const enemyCity = state.cities
.filter((c) => c.civ === 1)
.sort(
(a, b) =>
state.topology.tileDistance(own.coords, a.coords) -
state.topology.tileDistance(own.coords, b.coords)
)[0];
this.assertNotNull(enemyCity, "the fixture has an enemy city");
// 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 scenario already garrisons every city, 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"
);
state.advanceHour();
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 pair = freeAdjacentLand(state);
const attacker = state._spawnUnit(pair[0], 0, bomber, null);
const defender = state._spawnUnit(pair[1], 1, 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, pair[1].x, "the bomber flew onto the target");
this.assertEqual(attacker.coords.y, pair[1].y);
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");
+79
View File
@@ -28,6 +28,17 @@ function makeConfig() {
};
}
function landAtDistance(state, center, distance) {
for (const k in state.tiles) {
const parts = k.split(",").map(Number);
const coords = { x: parts[0], y: parts[1] };
if (state.tiles[k].terrainClass !== "Land") continue;
if (state.cityAt(coords) || state.unitAt(coords)) continue;
if (state.topology.tileDistance(center, coords) === distance) return coords;
}
return null;
}
export class GameScreenTest extends TestCase {
async test_renders_snapshot_and_sends_orders() {
const env = await setupDom();
@@ -332,6 +343,74 @@ export class GameScreenTest extends TestCase {
}
}
async test_air_strike_beyond_the_round_trip_is_refused() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
city.buildings[airport] = 1;
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const saved = jetProto.enduranceHours;
// Two hours of fuel is a ten-tile round trip, so a six-tile strike cannot
// get home even though it sits well inside the mission radius.
jetProto.enduranceHours = 2;
try {
const jet = state._spawnUnit(city.coords, 0, jetProto, city.id);
const goal = landAtDistance(state, city.coords, 6);
this.assertNotNull(goal, "the fixture has a tile six tiles away");
screen.onState(state.snapshot(0));
const unit = screen._unit(jet.id);
const reason = screen._airStrikeRangeError([unit], goal);
this.assertTrue(
typeof reason === "string" && reason.length > 0,
"the round trip is recognised as out of reach"
);
this.assertTrue(reason.includes("6 tiles away"), "the message names the target distance");
this.assertTrue(
reason.includes("effective range is 5 tiles"),
"the message names the plane's effective range"
);
const near = landAtDistance(state, city.coords, 4);
this.assertNotNull(near, "the fixture has a tile four tiles away");
this.assertNull(screen._airStrikeRangeError([unit], near), "a nearer target is in reach");
} finally {
jetProto.enduranceHours = saved;
}
screen.leave();
} finally {
teardownDom(env);
}
}
async test_air_strike_route_reaches_a_garrisoned_city() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
city.buildings[airport] = 1;
const bomberProto = state.protoUnits.find((u) => u.id === "bomber");
const bomber = state._spawnUnit(city.coords, 0, bomberProto, city.id);
const enemyCity = state.cities.find((c) => c.civ === 1);
screen.onState(state.snapshot(0));
const unit = screen._unit(bomber.id);
const route = await screen.map.planMove(unit, {
x: enemyCity.coords.x,
y: enemyCity.coords.y,
});
this.assertNotNull(route, "the client mirrors the server into the city tile");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_finishing_an_airport_unlocks_aircraft_in_an_open_panel() {
const env = await setupDom();
try {