From b532eb5e13f32d08ca3d63d11bd41423826d05f3 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Fri, 11 Sep 2026 21:30:46 -0600 Subject: [PATCH 1/3] Fix #8911: swarm missiles must not pick infantry sheltering in a building Fixes #8911 Leftover swarm missiles choose a secondary target from the units in the same or an adjacent hex (TO:AUE p.183). The search accepted any unit, including conventional infantry inside a building, which units outside cannot fire on directly (TW p.172). The leftover missiles then resolved against the platoon and the building soaked the damage, so a volley aimed at a building hit the infantry sheltering in it. Compute.getSwarmMissileTarget now skips a candidate that is infantry in a building unless the attacker's line of sight runs through that building, which is how a unit already inside legally shoots the platoon it shares the building with. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/megamek/common/compute/Compute.java | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index 19978e8d64f..16036860d16 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -6449,12 +6449,14 @@ public static Coords scatterAssaultDrop(Coords coords, int margin) { public static @Nullable Entity getSwarmMissileTarget(Game game, int aeId, Coords coords, int weaponId) { Entity tempEntity; + Entity attacker = game.getEntity(aeId); // first, check the hex of the original target Iterator entities = game.getEntities(coords); Vector possibleTargets = new Vector<>(); while (entities.hasNext()) { tempEntity = entities.next(); - if (!tempEntity.getTargetedBySwarm(aeId, weaponId)) { + if (!tempEntity.getTargetedBySwarm(aeId, weaponId) + && canBeSwarmedFrom(game, attacker, tempEntity)) { // we found a target possibleTargets.add(tempEntity); } @@ -6476,7 +6478,8 @@ public static Coords scatterAssaultDrop(Coords coords, int margin) { entities = game.getEntities(tempcoords); if (entities.hasNext()) { tempEntity = entities.next(); - if (!tempEntity.getTargetedBySwarm(aeId, weaponId)) { + if (!tempEntity.getTargetedBySwarm(aeId, weaponId) + && canBeSwarmedFrom(game, attacker, tempEntity)) { // we found a target possibleTargets.add(tempEntity); } @@ -6490,6 +6493,36 @@ public static Coords scatterAssaultDrop(Coords coords, int margin) { return null; } + /** + * Whether leftover swarm missiles may pick this unit as their secondary target. + * + *

Conventional infantry inside a building cannot be fired on directly from outside it; the attacker has to + * shoot the building hex instead (TW p.172). Leftover swarm missiles look for a target among the units nearby + * (TO:AUE p.183), and that search has to obey the same rule, or a volley aimed at a building ends up hitting + * the platoon sheltering in it.

+ * + *

Everything else is fair game, including infantry in the open and non-infantry inside the building, which + * the building absorption rules already cover.

+ * + * @param game the game being played + * @param attacker the unit that fired the volley, or {@code null} if it has left the board + * @param target the unit being considered as a secondary target + * + * @return {@code true} if the missiles may attack this unit + */ + private static boolean canBeSwarmedFrom(Game game, @Nullable Entity attacker, Entity target) { + boolean isShelteringInfantry = (target instanceof Infantry) && isInBuilding(game, target); + if (!isShelteringInfantry) { + return true; + } + if (attacker == null) { + return false; + } + // Fire that reaches them through the building itself is legal, which is how a unit already inside shoots + // the platoon it shares the building with. + return LosEffects.calculateLOS(game, attacker, target).getThruBldg() != null; + } + public static @Nullable Coords getFinalPosition(Coords currentPosition, int... v) { if ((v == null) || (v.length != 6) || (currentPosition == null)) { return currentPosition; From dd00dd9e682f5376e0742793be904c6bdb08a107 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 11:54:49 -0600 Subject: [PATCH 2/3] Address review: scan every unit in an adjacent hex, add coverage The adjacent-hex scan looked at one unit per hex. That was harmless before, but this branch adds a reason to reject a unit, so a hex whose first unit is infantry sheltering in a building contributed nothing and hid whatever else stood there. Now walks the hex, as the original-target scan already did. Adds SwarmMissileTargetTest: infantry in a building skipped from outside, infantry in the open still picked, a non-infantry unit in the building still picked, and a legal target behind a shelterer still found. The last one fails with the loop change reverted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../src/megamek/common/compute/Compute.java | 4 +- .../compute/SwarmMissileTargetTest.java | 152 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index 16036860d16..47fab171682 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -6476,7 +6476,9 @@ && canBeSwarmedFrom(game, attacker, tempEntity)) { continue; } entities = game.getEntities(tempcoords); - if (entities.hasNext()) { + // Every unit in the hex, not just the first: a hex whose first unit is infantry sheltering in a + // building would otherwise contribute nothing, hiding whatever else is standing there. + while (entities.hasNext()) { tempEntity = entities.next(); if (!tempEntity.getTargetedBySwarm(aeId, weaponId) && canBeSwarmedFrom(game, attacker, tempEntity)) { diff --git a/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java b/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java new file mode 100644 index 00000000000..e0dc70c82c9 --- /dev/null +++ b/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMek. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ +package megamek.common.compute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import megamek.common.Hex; +import megamek.common.Player; +import megamek.common.board.Board; +import megamek.common.board.Coords; +import megamek.common.equipment.EquipmentType; +import megamek.common.game.Game; +import megamek.common.units.BipedMek; +import megamek.common.units.ConvInfantry; +import megamek.common.units.Crew; +import megamek.common.units.CrewType; +import megamek.common.units.Entity; +import megamek.common.units.Mek; +import megamek.common.units.Terrain; +import megamek.common.units.Terrains; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Covers which units leftover swarm missiles may pick as a secondary target (GitHub issue #8911). Conventional + * infantry inside a building cannot be shot at from outside it (TW p.172), so the search that hands leftover missiles + * a nearby target has to skip them. + */ +class SwarmMissileTargetTest { + + private static final Coords BUILDING_HEX = new Coords(5, 5); + private static final Coords OPEN_HEX = new Coords(5, 4); + private static final Coords ATTACKER_HEX = new Coords(5, 2); + private static final int WEAPON_ID = 0; + private static final int BOARD_WIDTH = 16; + private static final int BOARD_HEIGHT = 17; + + private Game game; + private Entity attacker; + + @BeforeAll + static void beforeAll() { + EquipmentType.initializeTypes(); + } + + @BeforeEach + void beforeEach() { + game = new Game(); + game.addPlayer(0, new Player(0, "Test")); + // Built here rather than loaded from a board string: the test board loader fills hexes in file order + // rather than at the coordinates written, which would put the building in the wrong hex. + Board board = new Board(BOARD_WIDTH, BOARD_HEIGHT); + for (int x = 0; x < BOARD_WIDTH; x++) { + for (int y = 0; y < BOARD_HEIGHT; y++) { + board.setHex(x, y, new Hex()); + } + } + Hex buildingHex = board.getHex(BUILDING_HEX); + buildingHex.addTerrain(new Terrain(Terrains.BUILDING, 2)); + buildingHex.addTerrain(new Terrain(Terrains.BLDG_ELEV, 2)); + buildingHex.addTerrain(new Terrain(Terrains.BLDG_CF, 40)); + game.setBoard(board); + + attacker = addUnit(new BipedMek(), ATTACKER_HEX); + } + + private E addUnit(E entity, Coords position) { + entity.setOwner(game.getPlayer(0)); + entity.setId(game.getNextEntityId()); + entity.setGame(game); + entity.setCrew(new Crew(CrewType.SINGLE)); + // Position first, and deployed: the hex lookup skips anything that is not a targetable, on-board unit. + entity.setPosition(position); + entity.setDeployed(true); + game.addEntity(entity); + return entity; + } + + /** The original target hex is scanned first, so put the candidates there. */ + private Entity swarmTargetInBuildingHex() { + return Compute.getSwarmMissileTarget(game, attacker.getId(), BUILDING_HEX, WEAPON_ID); + } + + @Test + void infantryShelteringInABuildingIsNotPickedFromOutside() { + addUnit(new ConvInfantry(), BUILDING_HEX); + assertNull(swarmTargetInBuildingHex(), + "missiles from outside must not be handed infantry sheltering in a building"); + } + + @Test + void infantryInTheOpenIsStillPicked() { + ConvInfantry inTheOpen = addUnit(new ConvInfantry(), OPEN_HEX); + Entity picked = Compute.getSwarmMissileTarget(game, attacker.getId(), OPEN_HEX, WEAPON_ID); + assertSame(inTheOpen, picked, "infantry standing in the open is a legal secondary target"); + } + + @Test + void aMekInTheBuildingIsStillPicked() { + // The building absorption rules already cover a unit that is not infantry. + Mek insideTheBuilding = addUnit(new BipedMek(), BUILDING_HEX); + assertSame(insideTheBuilding, swarmTargetInBuildingHex(), + "only infantry gets the shelter, not everything standing in the hex"); + } + + @Test + void aUnitBehindShelteringInfantryIsStillFound() { + // The adjacent-hex scan used to look at one unit per hex, so a rejected shelterer hid whatever stood + // behind it. + addUnit(new ConvInfantry(), BUILDING_HEX); + Mek alsoThere = addUnit(new BipedMek(), BUILDING_HEX); + // Searching from the open hex reaches the building hex through the adjacent-hex scan, which is the loop + // that only ever looked at one unit. + Entity picked = Compute.getSwarmMissileTarget(game, attacker.getId(), OPEN_HEX, WEAPON_ID); + assertNotNull(picked, "a legal target in the hex must still be found"); + assertEquals(alsoThere.getId(), picked.getId(), "the legal target is the one that is not sheltering"); + } +} From be4c54c07b00c12acbb5e690b03e3f6eb38a58e8 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 21:08:25 -0600 Subject: [PATCH 3/3] Follow the swarm rules: no line of sight, and stop at infantry The line-of-sight exclusion was wrong. TO:AUE p.183 says plainly that "no form of line of sight from the attacker to the secondary target is required", so a platoon sheltering in a building is a legal secondary target. The earlier change applied the general rule that you cannot shoot infantry inside a building from outside (TW p.172), which the swarm rules explicitly override. Removed. The building still absorbs the damage. That is ordinary building protection and nothing in the swarm rules overrides it, so a platoon taking nothing from a volley fired from outside is correct. Stops the flight when conventional infantry is hit. Cluster weapons do not roll on the cluster table against infantry (TW p.215), so the platoon absorbs every remaining missile and the attack is over. Asked and answered officially: https://www.battletech.com/forums/index.php/topic,46840.msg1080115.html#msg1080115 calcHits returns early for infantry and never updated the remainder, so the missiles chained on to a further target instead of stopping. Keeps the adjacent-hex scan fix. Every unit at the same distance is a candidate chosen at random, so looking at one unit per hex silently narrowed the field. Confirmed load-bearing: the repeated test fails on every repetition with it reverted. No rules-level gate: Swarm LRMs are Advanced, not Core. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../src/megamek/common/compute/Compute.java | 40 +-------- .../weapons/handlers/lrm/LRMSwarmHandler.java | 6 ++ .../compute/SwarmMissileTargetTest.java | 89 +++++++++++-------- 3 files changed, 64 insertions(+), 71 deletions(-) diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index a781a489ba6..75df7ce634e 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -6443,14 +6443,12 @@ public static Coords scatterAssaultDrop(Coords coords, int margin) { public static @Nullable Entity getSwarmMissileTarget(Game game, int aeId, Coords coords, int weaponId) { Entity tempEntity; - Entity attacker = game.getEntity(aeId); // first, check the hex of the original target Iterator entities = game.getEntities(coords); Vector possibleTargets = new Vector<>(); while (entities.hasNext()) { tempEntity = entities.next(); - if (!tempEntity.getTargetedBySwarm(aeId, weaponId) - && canBeSwarmedFrom(game, attacker, tempEntity)) { + if (!tempEntity.getTargetedBySwarm(aeId, weaponId)) { // we found a target possibleTargets.add(tempEntity); } @@ -6470,12 +6468,11 @@ && canBeSwarmedFrom(game, attacker, tempEntity)) { continue; } entities = game.getEntities(tempcoords); - // Every unit in the hex, not just the first: a hex whose first unit is infantry sheltering in a - // building would otherwise contribute nothing, hiding whatever else is standing there. + // Every unit in the hex, not just the first. TO:AUE p.183 makes every unit at the same distance a + // candidate, chosen at random among them, so stopping at the first one silently narrowed the field. while (entities.hasNext()) { tempEntity = entities.next(); - if (!tempEntity.getTargetedBySwarm(aeId, weaponId) - && canBeSwarmedFrom(game, attacker, tempEntity)) { + if (!tempEntity.getTargetedBySwarm(aeId, weaponId)) { // we found a target possibleTargets.add(tempEntity); } @@ -6489,35 +6486,6 @@ && canBeSwarmedFrom(game, attacker, tempEntity)) { return null; } - /** - * Whether leftover swarm missiles may pick this unit as their secondary target. - * - *

Conventional infantry inside a building cannot be fired on directly from outside it; the attacker has to - * shoot the building hex instead (TW p.172). Leftover swarm missiles look for a target among the units nearby - * (TO:AUE p.183), and that search has to obey the same rule, or a volley aimed at a building ends up hitting - * the platoon sheltering in it.

- * - *

Everything else is fair game, including infantry in the open and non-infantry inside the building, which - * the building absorption rules already cover.

- * - * @param game the game being played - * @param attacker the unit that fired the volley, or {@code null} if it has left the board - * @param target the unit being considered as a secondary target - * - * @return {@code true} if the missiles may attack this unit - */ - private static boolean canBeSwarmedFrom(Game game, @Nullable Entity attacker, Entity target) { - boolean isShelteringInfantry = (target instanceof Infantry) && isInBuilding(game, target); - if (!isShelteringInfantry) { - return true; - } - if (attacker == null) { - return false; - } - // Fire that reaches them through the building itself is legal, which is how a unit already inside shoots - // the platoon it shares the building with. - return LosEffects.calculateLOS(game, attacker, target).getThruBldg() != null; - } public static @Nullable Coords getFinalPosition(Coords currentPosition, int... v) { if ((v == null) || (v.length != 6) || (currentPosition == null)) { diff --git a/megamek/src/megamek/common/weapons/handlers/lrm/LRMSwarmHandler.java b/megamek/src/megamek/common/weapons/handlers/lrm/LRMSwarmHandler.java index 0c4bdfa86a2..caa21aaca88 100644 --- a/megamek/src/megamek/common/weapons/handlers/lrm/LRMSwarmHandler.java +++ b/megamek/src/megamek/common/weapons/handlers/lrm/LRMSwarmHandler.java @@ -416,6 +416,12 @@ protected int calcHits(Vector vPhaseReport) { // conventional infantry gets hit in one lump // BAs do one lump of damage per BA suit if (target.isConventionalInfantry()) { + // A conventional platoon absorbs every remaining missile and the flight is over: cluster weapons do + // not roll on the cluster table against infantry (TW p.215), so there is nothing left to carry on + // with. Asked and answered officially - "That's a strange side-effect of the infantry rules, but yes": + // https://www.battletech.com/forums/index.php/topic,46840.msg1080115.html#msg1080115 + // Without this the remainder is never updated here and the missiles chain on to a further target. + swarmMissilesNowLeft = 0; if (attackingEntity instanceof BattleArmor) { bSalvo = true; return ((BattleArmor) attackingEntity).getShootingStrength(); diff --git a/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java b/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java index e0dc70c82c9..a584b55abde 100644 --- a/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java +++ b/megamek/unittests/megamek/common/compute/SwarmMissileTargetTest.java @@ -35,7 +35,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; import megamek.common.Hex; import megamek.common.Player; @@ -48,26 +51,29 @@ import megamek.common.units.Crew; import megamek.common.units.CrewType; import megamek.common.units.Entity; -import megamek.common.units.Mek; import megamek.common.units.Terrain; import megamek.common.units.Terrains; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; /** - * Covers which units leftover swarm missiles may pick as a secondary target (GitHub issue #8911). Conventional - * infantry inside a building cannot be shot at from outside it (TW p.172), so the search that hands leftover missiles - * a nearby target has to skip them. + * Which units leftover swarm missiles may pick as a secondary target (TO:AUE p.183). + * + *

The rule is deliberately wide: any unit, friendly or enemy, in the target's hex or an adjacent one, nearest + * first, chosen at random among those at the same distance, and "no form of line of sight from the attacker to the + * secondary target is required". A unit sheltering in a building is therefore a legal target, and so is the + * launching unit itself.

*/ class SwarmMissileTargetTest { + private static final int BOARD_WIDTH = 16; + private static final int BOARD_HEIGHT = 17; private static final Coords BUILDING_HEX = new Coords(5, 5); private static final Coords OPEN_HEX = new Coords(5, 4); - private static final Coords ATTACKER_HEX = new Coords(5, 2); + private static final Coords FAR_HEX = new Coords(10, 10); private static final int WEAPON_ID = 0; - private static final int BOARD_WIDTH = 16; - private static final int BOARD_HEIGHT = 17; private Game game; private Entity attacker; @@ -81,6 +87,7 @@ static void beforeAll() { void beforeEach() { game = new Game(); game.addPlayer(0, new Player(0, "Test")); + // Built here rather than loaded from a board string: the test board loader fills hexes in file order // rather than at the coordinates written, which would put the building in the wrong hex. Board board = new Board(BOARD_WIDTH, BOARD_HEIGHT); @@ -95,7 +102,7 @@ void beforeEach() { buildingHex.addTerrain(new Terrain(Terrains.BLDG_CF, 40)); game.setBoard(board); - attacker = addUnit(new BipedMek(), ATTACKER_HEX); + attacker = addUnit(new BipedMek(), FAR_HEX); } private E addUnit(E entity, Coords position) { @@ -110,43 +117,55 @@ private E addUnit(E entity, Coords position) { return entity; } - /** The original target hex is scanned first, so put the candidates there. */ - private Entity swarmTargetInBuildingHex() { - return Compute.getSwarmMissileTarget(game, attacker.getId(), BUILDING_HEX, WEAPON_ID); + private Entity swarmTargetAt(Coords coords) { + return Compute.getSwarmMissileTarget(game, attacker.getId(), coords, WEAPON_ID); } @Test - void infantryShelteringInABuildingIsNotPickedFromOutside() { - addUnit(new ConvInfantry(), BUILDING_HEX); - assertNull(swarmTargetInBuildingHex(), - "missiles from outside must not be handed infantry sheltering in a building"); + void infantryShelteringInABuildingIsStillAValidTarget() { + // TO:AUE p.183: no line of sight to the secondary target is required, so the building does not protect the + // platoon from being chosen. Whether the building then absorbs the damage is a separate question. + ConvInfantry sheltering = addUnit(new ConvInfantry(), BUILDING_HEX); + assertEquals(sheltering.getId(), swarmTargetAt(BUILDING_HEX).getId(), + "a platoon inside a building may be picked as a secondary target"); } @Test - void infantryInTheOpenIsStillPicked() { - ConvInfantry inTheOpen = addUnit(new ConvInfantry(), OPEN_HEX); - Entity picked = Compute.getSwarmMissileTarget(game, attacker.getId(), OPEN_HEX, WEAPON_ID); - assertSame(inTheOpen, picked, "infantry standing in the open is a legal secondary target"); + void aUnitAlreadyHitByThisFlightIsNotPickedAgain() { + // "Neither the original primary target nor any secondary targets may be attacked more than once." + ConvInfantry platoon = addUnit(new ConvInfantry(), BUILDING_HEX); + platoon.addTargetedBySwarm(attacker.getId(), WEAPON_ID); + assertNull(swarmTargetAt(BUILDING_HEX), + "a unit this flight has already attacked must not be picked again"); } @Test - void aMekInTheBuildingIsStillPicked() { - // The building absorption rules already cover a unit that is not infantry. - Mek insideTheBuilding = addUnit(new BipedMek(), BUILDING_HEX); - assertSame(insideTheBuilding, swarmTargetInBuildingHex(), - "only infantry gets the shelter, not everything standing in the hex"); + void theTargetsOwnHexIsPreferredOverAnAdjacentOne() { + // "starting from the nearest unit (beginning with any units in the target's hex and moving outward)" + Entity inTheHex = addUnit(new BipedMek(), BUILDING_HEX); + addUnit(new BipedMek(), OPEN_HEX); + assertEquals(inTheHex.getId(), swarmTargetAt(BUILDING_HEX).getId(), + "a unit in the target's own hex outranks one in an adjacent hex"); + } + + @RepeatedTest(20) + void everyUnitInAnAdjacentHexIsACandidate() { + // "If multiple secondary targets lie within the same distance, the secondary target is chosen at random." + // The scan used to look at one unit per adjacent hex, so the rest could never be picked. + Entity first = addUnit(new BipedMek(), BUILDING_HEX); + Entity second = addUnit(new BipedMek(), BUILDING_HEX); + Set everPicked = new HashSet<>(); + for (int attempt = 0; attempt < 40; attempt++) { + Entity picked = swarmTargetAt(OPEN_HEX); + assertNotNull(picked, "a candidate in the adjacent hex must be found"); + everPicked.add(picked.getId()); + } + assertTrue(everPicked.contains(first.getId()) && everPicked.contains(second.getId()), + "both units in the adjacent hex must be reachable, picked: " + everPicked); } @Test - void aUnitBehindShelteringInfantryIsStillFound() { - // The adjacent-hex scan used to look at one unit per hex, so a rejected shelterer hid whatever stood - // behind it. - addUnit(new ConvInfantry(), BUILDING_HEX); - Mek alsoThere = addUnit(new BipedMek(), BUILDING_HEX); - // Searching from the open hex reaches the building hex through the adjacent-hex scan, which is the loop - // that only ever looked at one unit. - Entity picked = Compute.getSwarmMissileTarget(game, attacker.getId(), OPEN_HEX, WEAPON_ID); - assertNotNull(picked, "a legal target in the hex must still be found"); - assertEquals(alsoThere.getId(), picked.getId(), "the legal target is the one that is not sheltering"); + void nothingNearbyMeansTheMissilesAreLost() { + assertNull(swarmTargetAt(OPEN_HEX), "with no unit in range the flight finds no target"); } }