From cf75b31e68f4beb3051f9ee8d41a2fe5c96a0a65 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Fri, 18 Sep 2026 22:46:19 +0200 Subject: [PATCH] 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 --- AGENTS.md | 9 +- client/js/game_screen.js | 15 ++- client/js/game_screen/panels.js | 60 ++++++++++ client/js/map_view/motion.js | 4 +- shared/data/units.js | 8 +- shared/game_state/air.js | 81 ++++++++----- shared/game_state/movement.js | 8 +- tests/air_test.js | 201 ++++++++++++++++++++++++++------ tests/game_screen_test.js | 79 +++++++++++++ 9 files changed, 392 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5fef790..64b454a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/client/js/game_screen.js b/client/js/game_screen.js index f68e320..2bf4938 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -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; diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 654b2a9..a96290f 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -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) { diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 3520277..4fc86d6 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -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; } diff --git a/shared/data/units.js b/shared/data/units.js index 82eb832..4ad2294 100644 --- a/shared/data/units.js +++ b/shared/data/units.js @@ -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", diff --git a/shared/game_state/air.js b/shared/game_state/air.js index c2ebc90..1cb253c 100644 --- a/shared/game_state/air.js +++ b/shared/game_state/air.js @@ -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; diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js index c153d23..03ceeb5 100644 --- a/shared/game_state/movement.js +++ b/shared/game_state/movement.js @@ -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; } diff --git a/tests/air_test.js b/tests/air_test.js index b775a2d..c019c91 100644 --- a/tests/air_test.js +++ b/tests/air_test.js @@ -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"); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index e60eb2d..878807e 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -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 {