Per TM pp.241-243, a chainsaw or dual saw can clear a path through wooded hexes. + * Instead of using the Terrain Factor damage system, a saw takes 2 turns to reduce a wooded hex one level (heavy to + * light, light to rough). Two units clearing the same hex can reduce this to 1 turn.
+ * + *Work accumulates per-hex and persists even if no entity works the hex for a round + * (a partially cut tree stays partially cut). Work only progresses when at least one + * entity actively declares clearing in a given round.
+ */ +public class WoodsClearingTracker implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** Number of work turns needed to clear a hex with a single saw. */ + public static final int TURNS_REQUIRED_SINGLE = 2; + + /** Number of work turns needed when 2+ saws are working the same hex. */ + public static final int TURNS_REQUIRED_MULTI = 1; + + private final MapFor each hex that had contributors this round, increments accumulated work. + * A hex completes when accumulated work reaches the required threshold (2 turns for single unit, + * 1 turn for 2+ units). Hexes with no contributors this round retain their accumulated work + * (a partially cut tree stays partially cut) but do not progress.
+ * + * @return list of hex locations that have completed clearing (ready for terrain reduction) + */ + public ListPer TM pp.241-243, a front-mounted saw on a vehicle can be used in a modified + * charge attack.
+ * + * @param entity the entity to check + * + * @return true if the entity is a Tank with a working front-mounted chainsaw or dual saw + */ + public static boolean hasFrontMountedSaw(Entity entity) { + return entity.hasFrontMountedSaw(); + } + + /** + * Returns the damage dealt by a vehicle saw charge attack. + * + *Per TM p.241, a front-mounted chainsaw deals 5 damage, and per TM p.243 a front-mounted + * dual saw deals 7 damage. Against conventional infantry, both deal 1d6 damage applied as + * infantry-on-infantry.
+ * + *If the attacker has both a dual saw and chainsaw, the dual saw takes priority + * (higher damage).
+ * + * @param attacker the charging vehicle + * @param target the target entity + * + * @return the flat damage for the saw charge, or 0 if no front-mounted saw + */ + public static int getSawChargeDamage(Entity attacker, Entity target) { + if (!attacker.hasFrontMountedSaw()) { + return 0; + } + // Check dual saw first (higher damage takes priority) + if (attacker.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)) { + return target.isConventionalInfantry() ? Compute.d6() : 7; + } + if (attacker.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)) { + return target.isConventionalInfantry() ? Compute.d6() : 5; + } + return 0; + } + + /** + * Returns the maximum (non-random) damage for a vehicle saw charge, used for damage display in the UI. Does not + * roll dice for infantry targets. + * + * @param attacker the charging vehicle + * @param target the target entity + * + * @return the flat damage value (5 or 7), or 0 if no front-mounted saw + */ + public static int getMaxSawChargeDamage(Entity attacker, Entity target) { + if (!attacker.hasFrontMountedSaw()) { + return 0; + } + if (attacker.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)) { + return target.isConventionalInfantry() ? 6 : 7; + } + if (attacker.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)) { + return target.isConventionalInfantry() ? 6 : 5; + } + return 0; + } + @Override public String toSummaryString(final Game game) { final String roll = this.toHit(game).getValueAsString(); diff --git a/megamek/src/megamek/common/actions/WoodsClearingAttackAction.java b/megamek/src/megamek/common/actions/WoodsClearingAttackAction.java new file mode 100644 index 00000000000..7ee263d75dc --- /dev/null +++ b/megamek/src/megamek/common/actions/WoodsClearingAttackAction.java @@ -0,0 +1,245 @@ +/* + * 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, seePer TM pp.241-243, a chainsaw or dual saw takes 2 turns to reduce a wooded hex + * one level (heavy to light, light to rough). Two units clearing the same hex reduce this to 1 turn. While clearing, + * the unit must remain in the hex or an adjacent hex, and weapon attacks are penalized as though moving at + * running/flank speed.
+ * + *This action is declared during the physical phase.
+ */ +public class WoodsClearingAttackAction extends AbstractAttackAction { + @Serial + private static final long serialVersionUID = 1L; + + private final int sawEquipmentId; + private final Coords targetCoords; + private final int targetBoardId; + + /** + * Creates a new woods clearing action. + * + * @param entityId the ID of the entity performing the clearing + * @param targetType the target type (should be Targetable.TYPE_HEX_CLEAR) + * @param targetId the target ID + * @param sawEquipmentId the equipment number of the saw being used + * @param targetCoords the coordinates of the hex being cleared + * @param targetBoardId the board ID of the hex being cleared + */ + public WoodsClearingAttackAction(int entityId, int targetType, int targetId, + int sawEquipmentId, Coords targetCoords, int targetBoardId) { + super(entityId, targetType, targetId); + this.sawEquipmentId = sawEquipmentId; + this.targetCoords = targetCoords; + this.targetBoardId = targetBoardId; + } + + public int getSawEquipmentId() { + return sawEquipmentId; + } + + public Coords getTargetCoords() { + return targetCoords; + } + + public int getTargetBoardId() { + return targetBoardId; + } + + /** + * Woods clearing is automatic (no roll needed) but may be impossible. + * + * @param game the current game + * + * @return AUTOMATIC_SUCCESS if clearing is valid, IMPOSSIBLE otherwise + */ + public ToHitData toHit(Game game) { + Entity ae = getEntity(game); + Targetable target = getTarget(game); + + if (ae == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Attacker not found"); + } + if (target == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target not found"); + } + + ToHitData validation = canClearWoods(game, ae, target.getPosition(), targetBoardId); + if (validation != null) { + return validation; + } + + return new ToHitData(TargetRoll.AUTOMATIC_SUCCESS, "clearing woods with saw"); + } + + /** + * Checks if the given entity can clear woods at the target hex. + * + * @param game the current game + * @param entity the entity attempting to clear + * @param targetCoords the hex coordinates to clear + * + * @return a ToHitData with IMPOSSIBLE if clearing is not valid, or null if it is valid + */ + public static ToHitData canClearWoods(Game game, Entity entity, Coords targetCoords, int boardId) { + if (entity == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Entity is null"); + } + if (targetCoords == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target hex is null"); + } + + // Entity must have a working chainsaw or dual saw + if (!hasWorkingSaw(entity)) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "No working chainsaw or dual saw"); + } + + // Entity must be in or adjacent to the target hex + Coords entityPos = entity.getPosition(); + if (entityPos == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Entity has no position"); + } + int distance = entityPos.distance(targetCoords); + if (distance > 1) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target hex is not adjacent"); + } + + // Target hex must have woods or jungle + Hex targetHex = game.getBoard(boardId).getHex(targetCoords); + if (targetHex == null) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target hex not found"); + } + if (!targetHex.containsTerrain(Terrains.WOODS) && !targetHex.containsTerrain(Terrains.JUNGLE)) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target hex has no woods or jungle"); + } + + // Entity must not be prone + if (entity.isProne()) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Cannot clear woods while prone"); + } + + // Entity must not be immobile + if (entity.isImmobile()) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Cannot clear woods while immobile"); + } + + // Target hex must be in the saw's attack arc (only matters for adjacent hexes) + if (distance == 1 && !isInSawArc(entity, targetCoords)) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target hex is not in the saw's attack arc"); + } + + return null; + } + + /** + * Checks if the entity has a working chainsaw or dual saw. + * + * @param entity the entity to check + * + * @return true if the entity has a functional saw + */ + public static boolean hasWorkingSaw(Entity entity) { + return entity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW) + || entity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW); + } + + /** + * Checks if a target hex is within the attack arc of at least one of the entity's working saws. The arc is + * determined by the saw's mounting location, following the same logic as club attacks. + * + * @param entity the entity with the saw + * @param targetCoords the target hex coordinates + * + * @return true if the target is in the arc of at least one working saw + */ + public static boolean isInSawArc(Entity entity, Coords targetCoords) { + for (MiscMounted misc : entity.getMisc()) { + if (!misc.isReady()) { + continue; + } + if (!misc.getType().hasFlag(MiscType.F_CLUB)) { + continue; + } + if (!misc.getType().hasFlag(MiscTypeFlag.S_CHAINSAW) + && !misc.getType().hasFlag(MiscTypeFlag.S_DUAL_SAW)) { + continue; + } + + // Determine the arc based on mounting location (same logic as ClubAttackAction) + int sawArc; + int location = misc.getLocation(); + if (location == Mek.LOC_LEFT_ARM) { + sawArc = Compute.ARC_LEFT_ARM; + } else if (location == Mek.LOC_RIGHT_ARM) { + sawArc = Compute.ARC_RIGHT_ARM; + } else if (misc.isRearMounted()) { + sawArc = Compute.ARC_REAR; + } else { + sawArc = Compute.ARC_FORWARD; + } + + if (ComputeArc.isInArc(entity.getPosition(), entity.getSecondaryFacing(), + targetCoords, sawArc)) { + return true; + } + } + return false; + } + + @Override + public String toSummaryString(final Game game) { + return Messages.getString("BoardView1.WoodsClearingAction"); + } +} diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index 9347f96509e..b0a2e7bbdf2 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -2824,6 +2824,12 @@ public static ToHitData getAttackerMovementModifier(Game game, int entityId, return new ToHitData(TargetRoll.AUTOMATIC_FAIL, "attacker sprinted"); } + // While clearing woods with a saw, weapon attacks are penalized as running/flank speed + // (TM pp.241-243). Apply minimum +2 modifier if not already at or above that level. + if (entity.isClearingWoods() && (toHit.getValue() < (2 / dedicatedGunnerMod))) { + toHit = new ToHitData(2 / dedicatedGunnerMod, "clearing woods with saw"); + } + return toHit; } diff --git a/megamek/src/megamek/common/game/Game.java b/megamek/src/megamek/common/game/Game.java index 5e48cc4a285..9e23de820de 100644 --- a/megamek/src/megamek/common/game/Game.java +++ b/megamek/src/megamek/common/game/Game.java @@ -53,6 +53,7 @@ import megamek.common.TagInfo; import megamek.common.Team; import megamek.common.TemporaryECMField; +import megamek.common.WoodsClearingTracker; import megamek.common.actions.ArtilleryAttackAction; import megamek.common.actions.AttackAction; import megamek.common.actions.EntityAction; @@ -179,6 +180,12 @@ public final class Game extends AbstractGame implements Serializable, PlanetaryC private final HashtablePer TM pp.241-243, a front-mounted saw on a vehicle can be used in a modified + * charge attack. By default, entities do not have front-mounted saws; only vehicles (Tank subclass) can have + * them.
+ * + * @return true if this entity has a working front-mounted chainsaw or dual saw + */ + public boolean hasFrontMountedSaw() { + return false; + } + /** * Returns the CriticalSlots in the given location as a list. The returned list can be empty depending on the unit * and the chosen slot but not null. The entries are not filtered in any way (could be null although that is @@ -7279,6 +7294,7 @@ public void newRound(int roundNumber) { setSpotting(false); spotTargetId = Entity.NONE; setClearingMinefield(false); + setClearingWoods(false); setUnjammingRAC(false); crew.setKoThisRound(false); m_lNarcedBy |= m_lPendingNarc; @@ -10263,6 +10279,27 @@ public void setClearingMinefield(boolean clearingMinefield) { this.clearingMinefield = clearingMinefield; } + /** + * Returns true if this entity is currently clearing woods with a saw. + * + *When clearing woods, weapon attacks are penalized as though the unit were + * moving at running/flank speed (TM pp.241-243).
+ * + * @return true if the entity is clearing woods + */ + public boolean isClearingWoods() { + return clearingWoods; + } + + /** + * Sets whether this entity is currently clearing woods with a saw. + * + * @param clearingWoods true if clearing woods + */ + public void setClearingWoods(boolean clearingWoods) { + this.clearingWoods = clearingWoods; + } + /** * @return True if this entity is spotting this round. */ @@ -11086,6 +11123,28 @@ public boolean isEligibleForPhysical() { } // Check the next building + // Check if the entity can clear woods with a saw (chainsaw or dual saw) + if (!canHit && position != null && WoodsClearingAttackAction.hasWorkingSaw(this)) { + // Own hex is always in arc + Hex ownHex = game.getHex(position, boardId); + if (ownHex != null && (ownHex.containsTerrain(Terrains.WOODS) || ownHex.containsTerrain(Terrains.JUNGLE))) { + canHit = true; + } + // Adjacent hexes must be in the saw's attack arc + if (!canHit) { + for (int dir = 0; dir < 6; dir++) { + Coords adj = position.translated(dir); + Hex adjHex = game.getBoard(boardId).getHex(adj); + if (adjHex != null && (adjHex.containsTerrain(Terrains.WOODS) + || adjHex.containsTerrain(Terrains.JUNGLE)) + && WoodsClearingAttackAction.isInSawArc(this, adj)) { + canHit = true; + break; + } + } + } + } + return canHit; } diff --git a/megamek/src/megamek/common/units/Tank.java b/megamek/src/megamek/common/units/Tank.java index c6130441a1d..4fa949b497d 100644 --- a/megamek/src/megamek/common/units/Tank.java +++ b/megamek/src/megamek/common/units/Tank.java @@ -435,6 +435,12 @@ public boolean isEligibleForPavementOrRoadBonus() { movementMode == EntityMovementMode.HOVER; } + @Override + public boolean hasFrontMountedSaw() { + return hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT) + || hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT); + } + public boolean isTurretLocked(int turret) { if (turret == getLocTurret()) { return m_bTurretLocked || m_bTurretJammed; diff --git a/megamek/src/megamek/server/totalWarfare/TWGameManager.java b/megamek/src/megamek/server/totalWarfare/TWGameManager.java index 0f80f8de8bd..8fd6d368533 100644 --- a/megamek/src/megamek/server/totalWarfare/TWGameManager.java +++ b/megamek/src/megamek/server/totalWarfare/TWGameManager.java @@ -163,6 +163,8 @@ public VectorRegisters the entity as clearing the target hex this round. The actual terrain + * reduction happens during round transition processing in {@link #processWoodsClearingCompletions()}.
+ */ + private void resolveWoodsClearingAction(PhysicalResult pr) { + final WoodsClearingAttackAction wca = (WoodsClearingAttackAction) pr.aaa; + final Entity ae = game.getEntity(wca.getEntityId()); + + if (ae == null) { + return; + } + + // Validate the action is still possible + if (pr.toHit.getValue() == TargetRoll.IMPOSSIBLE) { + // Silently skip if the hex was just cleared this phase (no need to alarm the player) + Hex checkHex = game.getBoard(wca.getTargetBoardId()).getHex(wca.getTargetCoords()); + if (checkHex != null && !checkHex.containsTerrain(Terrains.WOODS) + && !checkHex.containsTerrain(Terrains.JUNGLE)) { + return; + } + Report r = new Report(4075); + r.subject = ae.getId(); + r.add(pr.toHit.getDesc()); + addReport(r); + return; + } + + Coords targetCoords = wca.getTargetCoords(); + int targetBoardId = wca.getTargetBoardId(); + if (targetCoords == null) { + return; + } + + BoardLocation targetHex = BoardLocation.of(targetCoords, targetBoardId); + + // Use "continues" if hex already has accumulated work, "begins" if fresh start + boolean continuing = game.getWoodsClearingTracker().hasAccumulatedWork(targetHex); + + // Register clearing with tracker + game.getWoodsClearingTracker().declareClearing(ae.getId(), targetHex); + ae.setClearingWoods(true); + + // Sync clearing state to Game for board view rendering and send to clients + sendCutHexesUpdate(); + + // Report: entity is clearing woods + Report r = new Report(continuing ? 4501 : 4500); + r.subject = ae.getId(); + r.addDesc(ae); + r.add(targetCoords.getBoardNum()); + addReport(r); + } + + /** + * Processes completed woods clearing operations at the end of the physical phase. + * + *Called after all clearing declarations are resolved. Per TW p.112, terrain converts + * immediately when the threshold is met, so this runs in the same phase as the declarations. + * Checks all hexes being cleared and applies terrain reduction for any that + * have accumulated enough work turns.
+ */ + void processWoodsClearingCompletions() { + ListPer TM pp.241-243, a chainsaw or dual saw takes 2 turns to reduce a wooded hex + * one level. Two units clearing the same hex reduce this to 1 turn. Work accumulates per hex and persists across + * rounds (even if no entity clears in a given round) until a woods level is completely cleared.
+ */ +class WoodsClearingTrackerTest { + + private WoodsClearingTracker tracker; + private BoardLocation hexA; + private BoardLocation hexB; + + @BeforeEach + void setUp() { + tracker = new WoodsClearingTracker(); + hexA = BoardLocation.of(new Coords(5, 5), 0); + hexB = BoardLocation.of(new Coords(6, 6), 0); + } + + @Nested + @DisplayName("Single Saw Clearing (2 turns)") + class SingleSawTests { + + @Test + @DisplayName("One saw takes 2 turns to complete clearing") + void singleSawTwoTurns() { + // Turn 1: entity 1 declares clearing hex A + tracker.declareClearing(1, hexA); + ListA front-mounted chainsaw or dual saw on a vehicle can be used in a modified + * charge attack with +1 to-hit, flat damage (5 for chainsaw, 7 for dual saw), and altered hit tables.
+ */ +class SawChargeTest { + + /** + * Creates a mock Tank with a front-mounted chainsaw. + */ + private Tank createTankWithFrontChainsaw() { + Tank tank = mock(Tank.class); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)).thenReturn(true); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)).thenReturn(false); + when(tank.hasFrontMountedSaw()).thenReturn(true); + return tank; + } + + /** + * Creates a mock Tank with a front-mounted dual saw. + */ + private Tank createTankWithFrontDualSaw() { + Tank tank = mock(Tank.class); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)).thenReturn(false); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)).thenReturn(true); + when(tank.hasFrontMountedSaw()).thenReturn(true); + return tank; + } + + /** + * Creates a mock Tank with both front-mounted chainsaw and dual saw. + */ + private Tank createTankWithBothSaws() { + Tank tank = mock(Tank.class); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)).thenReturn(true); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)).thenReturn(true); + when(tank.hasFrontMountedSaw()).thenReturn(true); + return tank; + } + + /** + * Creates a mock Tank with no front-mounted saws. + */ + private Tank createTankWithNoSaw() { + Tank tank = mock(Tank.class); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW, Tank.LOC_FRONT)).thenReturn(false); + when(tank.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW, Tank.LOC_FRONT)).thenReturn(false); + when(tank.hasFrontMountedSaw()).thenReturn(false); + return tank; + } + + @Nested + @DisplayName("hasFrontMountedSaw()") + class HasFrontMountedSawTests { + + @Test + @DisplayName("Tank with front chainsaw returns true") + void tankWithFrontChainsaw() { + Tank tank = createTankWithFrontChainsaw(); + assertTrue(ChargeAttackAction.hasFrontMountedSaw(tank)); + } + + @Test + @DisplayName("Tank with front dual saw returns true") + void tankWithFrontDualSaw() { + Tank tank = createTankWithFrontDualSaw(); + assertTrue(ChargeAttackAction.hasFrontMountedSaw(tank)); + } + + @Test + @DisplayName("Tank with no front saw returns false") + void tankWithNoSaw() { + Tank tank = createTankWithNoSaw(); + assertFalse(ChargeAttackAction.hasFrontMountedSaw(tank)); + } + + @Test + @DisplayName("Mek is not eligible for saw charge") + void mekNotEligible() { + Mek mek = mock(Mek.class); + assertFalse(ChargeAttackAction.hasFrontMountedSaw(mek)); + } + + @Test + @DisplayName("Non-tank Entity is not eligible for saw charge") + void nonTankNotEligible() { + Entity entity = mock(Entity.class); + assertFalse(ChargeAttackAction.hasFrontMountedSaw(entity)); + } + } + + @Nested + @DisplayName("getSawChargeDamage()") + class GetSawChargeDamageTests { + + @Test + @DisplayName("Chainsaw deals 5 damage vs Mek") + void chainsawVsMek() { + Tank attacker = createTankWithFrontChainsaw(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(5, ChargeAttackAction.getSawChargeDamage(attacker, target), + "Chainsaw charge should deal 5 damage to Mek (TM p.241)"); + } + + @Test + @DisplayName("Dual saw deals 7 damage vs Mek") + void dualSawVsMek() { + Tank attacker = createTankWithFrontDualSaw(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(7, ChargeAttackAction.getSawChargeDamage(attacker, target), + "Dual saw charge should deal 7 damage to Mek (TM p.243)"); + } + + @Test + @DisplayName("Dual saw takes priority over chainsaw when both present") + void dualSawPriorityOverChainsaw() { + Tank attacker = createTankWithBothSaws(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(7, ChargeAttackAction.getSawChargeDamage(attacker, target), + "Dual saw should take priority over chainsaw (7 > 5)"); + } + + @Test + @DisplayName("Chainsaw deals 5 damage vs Tank") + void chainsawVsTank() { + Tank attacker = createTankWithFrontChainsaw(); + Tank target = createTankWithNoSaw(); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(5, ChargeAttackAction.getSawChargeDamage(attacker, target), + "Chainsaw charge should deal 5 damage to vehicles (TM p.241)"); + } + + @Test + @DisplayName("Chainsaw deals 1d6 vs conventional infantry") + void chainsawVsInfantry() { + Tank attacker = createTankWithFrontChainsaw(); + Infantry target = mock(Infantry.class); + when(target.isConventionalInfantry()).thenReturn(true); + + int damage = ChargeAttackAction.getSawChargeDamage(attacker, target); + assertTrue(damage >= 1 && damage <= 6, + "Chainsaw charge should deal 1d6 (1-6) damage to infantry, got: " + damage); + } + + @Test + @DisplayName("Dual saw deals 1d6 vs conventional infantry") + void dualSawVsInfantry() { + Tank attacker = createTankWithFrontDualSaw(); + Infantry target = mock(Infantry.class); + when(target.isConventionalInfantry()).thenReturn(true); + + int damage = ChargeAttackAction.getSawChargeDamage(attacker, target); + assertTrue(damage >= 1 && damage <= 6, + "Dual saw charge should deal 1d6 (1-6) damage to infantry, got: " + damage); + } + + @Test + @DisplayName("No front saw returns 0 damage") + void noSawReturnsZero() { + Tank attacker = createTankWithNoSaw(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(0, ChargeAttackAction.getSawChargeDamage(attacker, target), + "No front-mounted saw should return 0 damage"); + } + } + + @Nested + @DisplayName("getMaxSawChargeDamage()") + class GetMaxSawChargeDamageTests { + + @Test + @DisplayName("Chainsaw max damage is 5 vs non-infantry") + void chainsawMaxDamage() { + Tank attacker = createTankWithFrontChainsaw(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(5, ChargeAttackAction.getMaxSawChargeDamage(attacker, target)); + } + + @Test + @DisplayName("Dual saw max damage is 7 vs non-infantry") + void dualSawMaxDamage() { + Tank attacker = createTankWithFrontDualSaw(); + Mek target = mock(Mek.class); + when(target.isConventionalInfantry()).thenReturn(false); + + assertEquals(7, ChargeAttackAction.getMaxSawChargeDamage(attacker, target)); + } + + @Test + @DisplayName("Max damage vs infantry is 6 (max of 1d6)") + void maxDamageVsInfantry() { + Tank attacker = createTankWithFrontChainsaw(); + Infantry target = mock(Infantry.class); + when(target.isConventionalInfantry()).thenReturn(true); + + assertEquals(6, ChargeAttackAction.getMaxSawChargeDamage(attacker, target), + "Max saw charge damage vs infantry should be 6 (max of 1d6)"); + } + } +} diff --git a/megamek/unittests/megamek/common/actions/WoodsClearingAttackActionTest.java b/megamek/unittests/megamek/common/actions/WoodsClearingAttackActionTest.java new file mode 100644 index 00000000000..3719251108e --- /dev/null +++ b/megamek/unittests/megamek/common/actions/WoodsClearingAttackActionTest.java @@ -0,0 +1,226 @@ +/* + * 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, seePer TM pp.241-243, to clear woods an entity must have a working chainsaw or dual saw, + * be in or adjacent to a wooded hex, and not be prone or immobile.
+ */ +class WoodsClearingAttackActionTest { + + private Game mockGame; + private Board mockBoard; + private Entity mockEntity; + private Coords targetPos; + + @BeforeEach + void setUp() { + mockGame = mock(Game.class); + mockBoard = mock(Board.class); + when(mockGame.getBoard()).thenReturn(mockBoard); + when(mockGame.getBoard(0)).thenReturn(mockBoard); + + Coords entityPos = new Coords(5, 5); + targetPos = new Coords(5, 6); // Adjacent hex + + mockEntity = mock(Entity.class); + when(mockEntity.getPosition()).thenReturn(entityPos); + when(mockEntity.isProne()).thenReturn(false); + when(mockEntity.isImmobile()).thenReturn(false); + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW)).thenReturn(true); + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW)).thenReturn(false); + when(mockEntity.getSecondaryFacing()).thenReturn(3); // Facing south toward target + + // Set up a mock chainsaw in the CT (forward arc) for arc checking + MiscMounted mockSaw = mock(MiscMounted.class); + MiscType mockSawType = mock(MiscType.class); + when(mockSaw.isReady()).thenReturn(true); + when(mockSaw.getType()).thenReturn(mockSawType); + when(mockSaw.getLocation()).thenReturn(Mek.LOC_CENTER_TORSO); + when(mockSaw.isRearMounted()).thenReturn(false); + when(mockSawType.hasFlag(MiscType.F_CLUB)).thenReturn(true); + when(mockSawType.hasFlag(MiscTypeFlag.S_CHAINSAW)).thenReturn(true); + when(mockSawType.hasFlag(MiscTypeFlag.S_DUAL_SAW)).thenReturn(false); + when(mockEntity.getMisc()).thenReturn(List.of(mockSaw)); + + // Target hex has woods + Hex woodsHex = new Hex(); + woodsHex.addTerrain(new Terrain(Terrains.WOODS, 1)); + when(mockBoard.getHex(targetPos)).thenReturn(woodsHex); + } + + @Nested + @DisplayName("hasWorkingSaw()") + class HasWorkingSawTests { + + @Test + @DisplayName("Entity with chainsaw has working saw") + void entityWithChainsaw() { + assertTrue(WoodsClearingAttackAction.hasWorkingSaw(mockEntity)); + } + + @Test + @DisplayName("Entity with dual saw has working saw") + void entityWithDualSaw() { + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW)).thenReturn(false); + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW)).thenReturn(true); + assertTrue(WoodsClearingAttackAction.hasWorkingSaw(mockEntity)); + } + + @Test + @DisplayName("Entity with no saw does not have working saw") + void entityWithNoSaw() { + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW)).thenReturn(false); + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW)).thenReturn(false); + assertFalse(WoodsClearingAttackAction.hasWorkingSaw(mockEntity)); + } + } + + @Nested + @DisplayName("canClearWoods() validation") + class CanClearWoodsTests { + + @Test + @DisplayName("Valid clearing returns null (no error)") + void validClearing() { + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNull(result, "Valid clearing should return null (no error)"); + } + + @Test + @DisplayName("No working saw returns IMPOSSIBLE") + void noWorkingSaw() { + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_CHAINSAW)).thenReturn(false); + when(mockEntity.hasWorkingMisc(MiscType.F_CLUB, MiscTypeFlag.S_DUAL_SAW)).thenReturn(false); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNotNull(result); + assertEquals(TargetRoll.IMPOSSIBLE, result.getValue()); + } + + @Test + @DisplayName("Target hex too far returns IMPOSSIBLE") + void targetTooFar() { + Coords farPos = new Coords(10, 10); // More than 1 hex away + Hex farHex = new Hex(); + farHex.addTerrain(new Terrain(Terrains.WOODS, 1)); + when(mockBoard.getHex(farPos)).thenReturn(farHex); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, farPos, 0); + assertNotNull(result); + assertEquals(TargetRoll.IMPOSSIBLE, result.getValue()); + } + + @Test + @DisplayName("Target hex without woods returns IMPOSSIBLE") + void targetNoWoods() { + Hex clearHex = new Hex(); // No woods terrain + when(mockBoard.getHex(targetPos)).thenReturn(clearHex); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNotNull(result); + assertEquals(TargetRoll.IMPOSSIBLE, result.getValue()); + } + + @Test + @DisplayName("Target hex with jungle is valid") + void targetWithJungle() { + Hex jungleHex = new Hex(); + jungleHex.addTerrain(new Terrain(Terrains.JUNGLE, 1)); + when(mockBoard.getHex(targetPos)).thenReturn(jungleHex); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNull(result, "Jungle hex should be a valid clearing target"); + } + + @Test + @DisplayName("Prone entity returns IMPOSSIBLE") + void proneEntity() { + when(mockEntity.isProne()).thenReturn(true); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNotNull(result); + assertEquals(TargetRoll.IMPOSSIBLE, result.getValue()); + } + + @Test + @DisplayName("Immobile entity returns IMPOSSIBLE") + void immobileEntity() { + when(mockEntity.isImmobile()).thenReturn(true); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNotNull(result); + assertEquals(TargetRoll.IMPOSSIBLE, result.getValue()); + } + + @Test + @DisplayName("Entity in the target hex (distance 0) is valid") + void entityInTargetHex() { + // Entity is at the same position as the target + when(mockEntity.getPosition()).thenReturn(targetPos); + + ToHitData result = WoodsClearingAttackAction.canClearWoods(mockGame, mockEntity, targetPos, 0); + assertNull(result, "Entity in the target hex should be valid"); + } + } +} diff --git a/megamek/unittests/megamek/server/victory/GameManagerTest.java b/megamek/unittests/megamek/server/victory/GameManagerTest.java index 0b33589d4f4..35e5700ff18 100644 --- a/megamek/unittests/megamek/server/victory/GameManagerTest.java +++ b/megamek/unittests/megamek/server/victory/GameManagerTest.java @@ -46,6 +46,7 @@ import megamek.client.ui.util.PlayerColour; import megamek.common.Player; +import megamek.common.WoodsClearingTracker; import megamek.common.force.Forces; import megamek.common.game.Game; import megamek.common.options.GameOptions; @@ -64,6 +65,7 @@ protected Game createMockedGame() { when(testGame.getAttacksVector()).thenReturn(new Vector<>()); when(testGame.getForces()).thenReturn(testForces); when(testGame.getOptions()).thenReturn(new GameOptions()); + when(testGame.getWoodsClearingTracker()).thenReturn(new WoodsClearingTracker()); return testGame; }