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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user