diff --git a/megamek/resources/megamek/client/messages.properties b/megamek/resources/megamek/client/messages.properties index 9b334a9506f..ff18ed4a8d9 100644 --- a/megamek/resources/megamek/client/messages.properties +++ b/megamek/resources/megamek/client/messages.properties @@ -338,6 +338,7 @@ BoardView1.ConversionMode.Aerodyne=Fighter BoardView1.ConversionMode.UMU=UMU BoardView1.ChargeAttackAction=Charges. Needs {0} BoardView1.ChargeAttackAction1=(Charging) +BoardView1.WoodsClearingAction=Clearing Woods BoardView1.CrewDead=CREW DEAD BoardView1.DfaAttackAction1=(Executing DFA) BoardView1.Drop=Drop @@ -588,6 +589,8 @@ BoardView1.Tooltip.BuildingLine=Height: {0}; CF: {1} Armor: {2} BoardView1.Tooltip.BldgBasementCollapsed=
(collapsed) BoardView1.Tooltip.Bridge={1}
Height: {0}, CF: {2} BoardView1.Tooltip.FuelTank={1}
Height: {0}, CF: {2}
Magnitude: {3} +BoardView1.Tooltip.WoodsClearing=Saw clearing in progress ({0} turn(s) remaining) +BoardView1.Tooltip.WoodsClearingComplete=Saw clearing complete BoardView1.Tooltip.ArtyAutoHint1=You can show all players' BoardView1.Tooltip.ArtyAutoHint2=deployment zones on the board BoardView1.Tooltip.ArtyAutoHint3=by pressing {0}. @@ -3146,6 +3149,8 @@ PhysicalDisplay.protoPhysical=Proto-Frenzy PhysicalDisplay.LayExplosivesAttackDialog.message=To Hit: {0} ({1}%) ({2}) PhysicalDisplay.LayExplosivesAttackDialog.title=Lay explosives on {0}? PhysicalDisplay.explosives=Lay Explosives +PhysicalDisplay.clearWoods=Clear Woods +PhysicalDisplay.SelectClearWoodsHex=Click a wooded hex to clear with saw. PhysicalDisplay.infantryCombat=Infantry Combat PhysicalDisplay.InfantryCombatDialog.title=Initiate Infantry Combat? PhysicalDisplay.InfantryCombatDialog.message={0} will engage in infantry vs. infantry combat at {1}. Continue? diff --git a/megamek/resources/megamek/common/report-messages.properties b/megamek/resources/megamek/common/report-messages.properties index 86198bf44ff..539a64fe3f5 100755 --- a/megamek/resources/megamek/common/report-messages.properties +++ b/megamek/resources/megamek/common/report-messages.properties @@ -706,6 +706,10 @@ 4450= tries to trip . Needs rolls 4455=Retractable blade extended: 4456=The retractable blade is destroyed! +# Woods Clearing with Saw (TM pp.241-243) +4500= () begins clearing woods at hex with a saw. +4501= () continues clearing woods at hex with a saw. +4502=Woods at hex have been reduced by saw clearing! 4550=Pheromone attack at 4551=, but the pheromone attack is impossible (). 4552=misses diff --git a/megamek/src/megamek/client/Client.java b/megamek/src/megamek/client/Client.java index 4c9eef8351b..2b4b46bca2b 100644 --- a/megamek/src/megamek/client/Client.java +++ b/megamek/src/megamek/client/Client.java @@ -699,6 +699,11 @@ protected void receiveIlluminatedHexes(Packet packet) throws InvalidPacketDataEx game.setIlluminatedPositions(packet.getCoordsHashSet(0)); } + protected void receiveUpdateCutHexes(Packet packet) throws InvalidPacketDataException { + game.setHexesBeingCut(packet.getBoardLocationIntegerMap(0)); + game.processGameEvent(new GameBoardChangeEvent(this)); + } + protected void receiveRevealMinefield(Packet packet) throws InvalidPacketDataException { Minefield minefield = packet.getMinefield(0); @@ -1036,6 +1041,9 @@ protected boolean handleGameSpecificPacket(Packet packet) { case REMOVE_MINEFIELD: receiveRemoveMinefield(packet); break; + case UPDATE_CUT_HEXES: + receiveUpdateCutHexes(packet); + break; case UPDATE_GROUND_OBJECTS: receiveUpdateGroundObjects(packet); break; diff --git a/megamek/src/megamek/client/ui/clientGUI/ClientGUI.java b/megamek/src/megamek/client/ui/clientGUI/ClientGUI.java index 2e8dca60d30..22e263c550a 100644 --- a/megamek/src/megamek/client/ui/clientGUI/ClientGUI.java +++ b/megamek/src/megamek/client/ui/clientGUI/ClientGUI.java @@ -85,10 +85,10 @@ import megamek.client.ui.clientGUI.boardview.RulerDialog; import megamek.client.ui.clientGUI.boardview.overlay.BoardToastOverlay; import megamek.client.ui.clientGUI.boardview.overlay.ChatterBoxOverlay; -import megamek.client.ui.clientGUI.boardview.overlay.ToastLevel; import megamek.client.ui.clientGUI.boardview.overlay.KeyBindingsOverlay; import megamek.client.ui.clientGUI.boardview.overlay.OffBoardTargetOverlay; import megamek.client.ui.clientGUI.boardview.overlay.PlanetaryConditionsOverlay; +import megamek.client.ui.clientGUI.boardview.overlay.ToastLevel; import megamek.client.ui.clientGUI.boardview.overlay.TurnDetailsOverlay; import megamek.client.ui.clientGUI.boardview.overlay.UnitOverviewOverlay; import megamek.client.ui.clientGUI.boardview.spriteHandler.*; @@ -352,6 +352,7 @@ public class ClientGUI extends AbstractClientGUI private FleeZoneSpriteHandler fleeZoneSpriteHandler; private SensorRangeSpriteHandler sensorRangeSpriteHandler; private CollapseWarningSpriteHandler collapseWarningSpriteHandler; + private SawClearingSpriteHandler sawClearingSpriteHandler; private GroundObjectSpriteHandler groundObjectSpriteHandler; private FiringSolutionSpriteHandler firingSolutionSpriteHandler; private FiringArcSpriteHandler firingArcSpriteHandler; @@ -697,6 +698,7 @@ private void initializeSpriteHandlers() { FlareSpritesHandler flareSpritesHandler = new FlareSpritesHandler(this, client.getGame()); sensorRangeSpriteHandler = new SensorRangeSpriteHandler(this, client.getGame()); collapseWarningSpriteHandler = new CollapseWarningSpriteHandler(this); + sawClearingSpriteHandler = new SawClearingSpriteHandler(this, client.getGame()); groundObjectSpriteHandler = new GroundObjectSpriteHandler(this, client.getGame()); firingSolutionSpriteHandler = new FiringSolutionSpriteHandler(this, client); firingArcSpriteHandler = new FiringArcSpriteHandler(this); @@ -707,6 +709,7 @@ private void initializeSpriteHandlers() { sensorRangeSpriteHandler, flareSpritesHandler, collapseWarningSpriteHandler, + sawClearingSpriteHandler, groundObjectSpriteHandler, firingSolutionSpriteHandler, firingArcSpriteHandler, @@ -3619,6 +3622,15 @@ public void showCollapseWarning(Collection warnList) { collapseWarningSpriteHandler.setCFWarningSprites(warnList); } + /** + * Shows saw clearing indicators on the given hexes in the BoardView. + * + * @param cutHexes a map of board locations to turns remaining for saw clearing + */ + public void showSawClearingHexes(Map cutHexes) { + sawClearingSpriteHandler.setSawClearingSprites(cutHexes); + } + /** * Shows ground object icons in the given list of Coords in the BoardView * diff --git a/megamek/src/megamek/client/ui/clientGUI/boardview/sprite/SawClearingSprite.java b/megamek/src/megamek/client/ui/clientGUI/boardview/sprite/SawClearingSprite.java new file mode 100644 index 00000000000..0a4808eda2b --- /dev/null +++ b/megamek/src/megamek/client/ui/clientGUI/boardview/sprite/SawClearingSprite.java @@ -0,0 +1,162 @@ +/* + * 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.client.ui.clientGUI.boardview.sprite; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Stroke; + +import megamek.client.ui.clientGUI.boardview.BoardView; +import megamek.client.ui.tileset.HexTileset; +import megamek.client.ui.util.UIUtil; +import megamek.common.board.Coords; + +/** + * Displays a circular saw blade indicator on hexes that are being cleared by saws. Shows a steel gray blade with + * triangular teeth and a turns-remaining number in the center, positioned in the lower portion of the hex. + */ +public class SawClearingSprite extends HexSprite { + + private static final Color SAW_TOOTH_COLOR = new Color(100, 100, 110); + private static final Color SAW_BLADE_COLOR = new Color(170, 170, 180); + private static final Color SAW_INNER_COLOR = new Color(130, 130, 140); + private static final Color SAW_TEXT_OUTLINE_COLOR = new Color(40, 40, 50); + private static final Color SAW_OUTLINE_COLOR = new Color(60, 60, 70); + + private static final int HEX_CENTER_X = HexTileset.HEX_W / 2; + private static final int BLADE_RADIUS = 10; + private static final int TOOTH_HEIGHT = 4; + private static final int NUM_TEETH = 12; + private static final int INNER_RADIUS = 6; + private static final int FONT_SIZE = 11; + private static final int BOTTOM_OFFSET = 20; + + private final int turnsRemaining; + + /** + * Creates a new saw clearing sprite for the given hex. + * + * @param boardView the parent board view + * @param loc the hex coordinates + * @param turnsRemaining the number of turns remaining to complete clearing + */ + public SawClearingSprite(BoardView boardView, Coords loc, int turnsRemaining) { + super(boardView, loc); + this.turnsRemaining = turnsRemaining; + } + + @Override + public void prepare() { + Graphics2D graph = spriteSetup(); + drawSawBlade(graph); + graph.dispose(); + } + + private Graphics2D spriteSetup() { + updateBounds(); + image = createNewHexImage(); + Graphics2D graph = (Graphics2D) image.getGraphics(); + UIUtil.setHighQualityRendering(graph); + graph.scale(bv.getScale(), bv.getScale()); + return graph; + } + + private void drawSawBlade(Graphics2D graph) { + graph.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Position in lower portion of hex, centered horizontally + int centerX = HEX_CENTER_X; + int centerY = HexTileset.HEX_H - BOTTOM_OFFSET; + + // Draw outer teeth (dark steel color) + graph.setColor(SAW_TOOTH_COLOR); + for (int i = 0; i < NUM_TEETH; i++) { + double angle = (2 * Math.PI * i) / NUM_TEETH; + double nextAngle = (2 * Math.PI * (i + 0.5)) / NUM_TEETH; + int outerX = centerX + (int) ((BLADE_RADIUS + TOOTH_HEIGHT) * Math.cos(angle)); + int outerY = centerY + (int) ((BLADE_RADIUS + TOOTH_HEIGHT) * Math.sin(angle)); + int leftX = centerX + (int) (BLADE_RADIUS * Math.cos(angle - 0.15)); + int leftY = centerY + (int) (BLADE_RADIUS * Math.sin(angle - 0.15)); + int rightX = centerX + (int) (BLADE_RADIUS * Math.cos(nextAngle)); + int rightY = centerY + (int) (BLADE_RADIUS * Math.sin(nextAngle)); + int[] xPoints = { outerX, leftX, rightX }; + int[] yPoints = { outerY, leftY, rightY }; + graph.fillPolygon(xPoints, yPoints, 3); + } + + // Draw blade body (steel gray) + graph.setColor(SAW_BLADE_COLOR); + graph.fillOval(centerX - BLADE_RADIUS, centerY - BLADE_RADIUS, + BLADE_RADIUS * 2, BLADE_RADIUS * 2); + + // Draw inner ring (darker) + graph.setColor(SAW_INNER_COLOR); + graph.fillOval(centerX - INNER_RADIUS, centerY - INNER_RADIUS, + INNER_RADIUS * 2, INNER_RADIUS * 2); + + // Draw turns remaining number in the center of the blade + String turnsStr = String.valueOf(turnsRemaining); + Font turnsFont = new Font("Sans Serif", Font.BOLD, FONT_SIZE); + FontMetrics fm = graph.getFontMetrics(turnsFont); + int textWidth = fm.stringWidth(turnsStr); + int textX = centerX - (textWidth / 2); + int textY = centerY + (fm.getAscent() / 2) - 1; + + // White text with dark outline for readability + graph.setFont(turnsFont); + graph.setColor(SAW_TEXT_OUTLINE_COLOR); + graph.drawString(turnsStr, textX - 1, textY); + graph.drawString(turnsStr, textX + 1, textY); + graph.drawString(turnsStr, textX, textY - 1); + graph.drawString(turnsStr, textX, textY + 1); + graph.setColor(Color.WHITE); + graph.drawString(turnsStr, textX, textY); + + // Draw blade outline + graph.setColor(SAW_OUTLINE_COLOR); + Stroke oldStroke = graph.getStroke(); + graph.setStroke(new BasicStroke(1)); + graph.drawOval(centerX - BLADE_RADIUS, centerY - BLADE_RADIUS, + BLADE_RADIUS * 2, BLADE_RADIUS * 2); + graph.setStroke(oldStroke); + } + + @Override + public boolean isBehindTerrain() { + return false; + } +} diff --git a/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/SawClearingSpriteHandler.java b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/SawClearingSpriteHandler.java new file mode 100644 index 00000000000..8566dbb0645 --- /dev/null +++ b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/SawClearingSpriteHandler.java @@ -0,0 +1,96 @@ +/* + * 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.client.ui.clientGUI.boardview.spriteHandler; + +import java.util.Map; + +import megamek.client.ui.clientGUI.AbstractClientGUI; +import megamek.client.ui.clientGUI.boardview.BoardView; +import megamek.client.ui.clientGUI.boardview.sprite.SawClearingSprite; +import megamek.common.board.BoardLocation; +import megamek.common.event.board.GameBoardChangeEvent; +import megamek.common.game.Game; + +/** + * Manages saw clearing indicator sprites on the board view. Creates and removes {@link SawClearingSprite} instances to + * show which hexes are being cleared by vehicle-mounted saws. + */ +public class SawClearingSpriteHandler extends BoardViewSpriteHandler { + + private final Game game; + + public SawClearingSpriteHandler(AbstractClientGUI clientGUI, Game game) { + super(clientGUI); + this.game = game; + } + + /** + * Updates the saw clearing sprites to reflect the given cut hex data. + * + * @param cutHexes a map of board locations to turns remaining, or null to clear + */ + public void setSawClearingSprites(Map cutHexes) { + clear(); + if (clientGUI.boardViews().isEmpty()) { + return; + } + if (cutHexes != null) { + for (Map.Entry entry : cutHexes.entrySet()) { + BoardLocation location = entry.getKey(); + BoardView boardView = (BoardView) clientGUI.getBoardView(location); + if (boardView != null) { + SawClearingSprite sprite = new SawClearingSprite( + boardView, location.coords(), entry.getValue()); + currentSprites.add(sprite); + } + } + } + currentSprites.forEach(sprite -> sprite.bv.addSprite(sprite)); + } + + @Override + public void initialize() { + game.addGameListener(this); + } + + @Override + public void dispose() { + clear(); + game.removeGameListener(this); + } + + @Override + public void gameBoardChanged(GameBoardChangeEvent e) { + setSawClearingSprites(game.getHexesBeingCut()); + } +} diff --git a/megamek/src/megamek/client/ui/clientGUI/tooltip/HexTooltip.java b/megamek/src/megamek/client/ui/clientGUI/tooltip/HexTooltip.java index e811d6c6995..fe779bf2705 100644 --- a/megamek/src/megamek/client/ui/clientGUI/tooltip/HexTooltip.java +++ b/megamek/src/megamek/client/ui/clientGUI/tooltip/HexTooltip.java @@ -37,6 +37,7 @@ import java.awt.Point; import java.util.List; +import java.util.Map; import java.util.Vector; import java.util.stream.Collectors; @@ -53,6 +54,7 @@ import megamek.common.ReportMessages; import megamek.common.annotations.Nullable; import megamek.common.board.Board; +import megamek.common.board.BoardLocation; import megamek.common.board.Coords; import megamek.common.enums.BasementType; import megamek.common.equipment.FuelTank; @@ -230,6 +232,26 @@ public static String getHexTip(Hex mhex, @Nullable Client client, int boardId) { } } + // Woods clearing indicator + if (game != null) { + Map cutHexes = game.getHexesBeingCut(); + BoardLocation thisLoc = BoardLocation.of(mcoords, boardId); + Integer turnsRemaining = cutHexes.get(thisLoc); + if (turnsRemaining != null) { + String cutInfo; + if (turnsRemaining <= 0) { + cutInfo = Messages.getString("BoardView1.Tooltip.WoodsClearingComplete"); + } else { + cutInfo = Messages.getString("BoardView1.Tooltip.WoodsClearing", turnsRemaining); + } + String attr = String.format("FACE=Dialog COLOR=%s", + UIUtil.toColorHexString(GUIP.getUnitToolTipFGColor())); + cutInfo = UIUtil.tag("FONT", attr, cutInfo); + result.append(cutInfo); + result.append("
"); + } + } + return result.toString(); } diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/MovementDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/MovementDisplay.java index 7ea13643036..e1a8e13e281 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/MovementDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/MovementDisplay.java @@ -2043,10 +2043,17 @@ && hasLandingMoveStep()) { cmd.getHexesMoved()); toDefender = AirMekRamAttackAction.getDamageFor(currentlySelectedEntity, cmd.getHexesMoved()); } else { - toDefender = ChargeAttackAction.getDamageFor( - currentlySelectedEntity, game.getOptions() - .booleanOption(OptionsConstants.ADVANCED_COMBAT_TAC_OPS_CHARGE_DAMAGE), - cmd.getHexesMoved()); + // Front-mounted saw charge uses flat damage (TM pp.241-243) + if (ChargeAttackAction.hasFrontMountedSaw(currentlySelectedEntity) + && target.getTargetType() == Targetable.TYPE_ENTITY) { + toDefender = ChargeAttackAction.getMaxSawChargeDamage( + currentlySelectedEntity, (Entity) target); + } else { + toDefender = ChargeAttackAction.getDamageFor( + currentlySelectedEntity, game.getOptions() + .booleanOption(OptionsConstants.ADVANCED_COMBAT_TAC_OPS_CHARGE_DAMAGE), + cmd.getHexesMoved()); + } if (target.getTargetType() == Targetable.TYPE_ENTITY) { Entity te = (Entity) target; toAttacker = ChargeAttackAction.getDamageTakenBy(currentlySelectedEntity, diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/PhysicalDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/PhysicalDisplay.java index 49553fca630..efc0f769931 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/PhysicalDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/PhysicalDisplay.java @@ -65,6 +65,8 @@ import megamek.client.ui.widget.IndexedRadioButton; import megamek.client.ui.widget.MegaMekButton; import megamek.client.ui.widget.MekPanelTabStrip; +import megamek.common.Hex; +import megamek.common.HexTarget; import megamek.common.ToHitData; import megamek.common.actions.*; import megamek.common.board.Board; @@ -74,6 +76,7 @@ import megamek.common.enums.AimingMode; import megamek.common.equipment.INarcPod; import megamek.common.equipment.MiscMounted; +import megamek.common.equipment.MiscType; import megamek.common.equipment.Mounted; import megamek.common.equipment.enums.MiscTypeFlag; import megamek.common.event.GamePhaseChangeEvent; @@ -89,6 +92,7 @@ import megamek.common.units.Mek; import megamek.common.units.QuadMek; import megamek.common.units.Targetable; +import megamek.common.units.Terrains; import megamek.logging.MMLogger; public class PhysicalDisplay extends AttackPhaseDisplay { @@ -103,6 +107,9 @@ public class PhysicalDisplay extends AttackPhaseDisplay { protected int lastTargetID = -1; protected boolean isStrafing = false; + /** When true, the next hex click selects the target for a woods clearing action. */ + private boolean selectingClearWoodsHex = false; + /** * This enumeration lists all the possible ActionCommands that can be carried out during the physical phase. Each * command has a string for the command plus a flag that determines what unit type it is appropriate for. @@ -127,6 +134,7 @@ public enum PhysicalCommand implements PhaseCommand { PHYSICAL_VIBRO("vibro"), PHYSICAL_PHEROMONE("pheromone"), PHYSICAL_TOXIN("toxin"), + PHYSICAL_CLEAR_WOODS("clearWoods"), PHYSICAL_MORE("more"); final String cmd; @@ -479,6 +487,37 @@ public void selectEntity(int en) { if ((entity instanceof Mek) && !entity.isProne() && entity.hasAbility(OptionsConstants.PILOT_DODGE_MANEUVER)) { setDodgeEnabled(true); } + // Enable clear woods button if entity has a working saw and is near woods in arc + boolean hasSaw = WoodsClearingAttackAction.hasWorkingSaw(entity); + logger.debug("Clear woods check for {}: hasSaw={}, prone={}, immobile={}, position={}", + entity.getDisplayName(), hasSaw, entity.isProne(), entity.isImmobile(), entity.getPosition()); + if (hasSaw && !entity.isProne() && !entity.isImmobile()) { + boolean nearWoods = false; + // Own hex is always in arc + Hex ownHex = game.getBoard(entity.getBoardId()).getHex(entity.getPosition()); + if (ownHex != null && (ownHex.containsTerrain(Terrains.WOODS) || ownHex.containsTerrain(Terrains.JUNGLE))) { + nearWoods = true; + logger.debug(" Entity's own hex has woods/jungle"); + } + // Adjacent hexes must be in the saw's arc + if (!nearWoods) { + for (int dir = 0; dir < 6; dir++) { + Coords adj = entity.getPosition().translated(dir); + Hex adjHex = game.getBoard(entity.getBoardId()).getHex(adj); + if (adjHex != null && (adjHex.containsTerrain(Terrains.WOODS) + || adjHex.containsTerrain(Terrains.JUNGLE)) + && WoodsClearingAttackAction.isInSawArc(entity, adj)) { + nearWoods = true; + logger.debug(" Found woods/jungle in arc at direction {} coords {}", dir, adj); + break; + } + } + } + logger.debug(" nearWoods={}, enabling clear woods button: {}", nearWoods, nearWoods); + setClearWoodsEnabled(nearWoods); + } else if (hasSaw) { + logger.debug(" Has saw but prone or immobile, not enabling clear woods"); + } updateDonePanel(); cacheVisibleTargets(); } @@ -554,6 +593,8 @@ private void disableButtons() { setPheromoneEnabled(false); setToxinEnabled(false); setExplosivesEnabled(false); + setClearWoodsEnabled(false); + selectingClearWoodsHex = false; butDone.setEnabled(false); setNextEnabled(false); } @@ -1287,6 +1328,60 @@ private void proto() { } } + /** + * Enters hex selection mode for woods clearing. The player must click a wooded hex on the map to select the + * clearing target. + */ + private void clearWoods() { + if (currentEntity() == null || !WoodsClearingAttackAction.hasWorkingSaw(currentEntity())) { + return; + } + selectingClearWoodsHex = true; + setStatusBarText(Messages.getString("PhysicalDisplay.SelectClearWoodsHex")); + } + + /** + * Completes the woods clearing action after the player has selected a target hex. + * + * @param targetCoords the hex to clear + * @param boardId the board ID of the target hex + */ + private void completeClearWoods(Coords targetCoords, int boardId) { + Entity entity = currentEntity(); + if (entity == null) { + return; + } + + // Validate the selected hex + ToHitData validation = WoodsClearingAttackAction.canClearWoods(game, entity, targetCoords, entity.getBoardId()); + if (validation != null) { + setStatusBarText(validation.getDesc()); + return; + } + + // Find the saw equipment ID + int sawId = -1; + for (MiscMounted misc : entity.getMisc()) { + if (misc.isReady() && misc.getType().hasFlag(MiscType.F_CLUB) + && (misc.getType().hasFlag(MiscTypeFlag.S_CHAINSAW) + || misc.getType().hasFlag(MiscTypeFlag.S_DUAL_SAW))) { + sawId = entity.getEquipmentNum(misc); + break; + } + } + + if (sawId < 0) { + return; + } + + HexTarget hexTarget = new HexTarget(targetCoords, boardId, Targetable.TYPE_HEX_CLEAR); + + disableButtons(); + addAttack(new WoodsClearingAttackAction(currentEntity, hexTarget.getTargetType(), + hexTarget.getId(), sawId, targetCoords, boardId)); + ready(); + } + private void explosives() { ToHitData explosives = LayExplosivesAttackAction.toHit(game, currentEntity, target); String title = Messages.getString("PhysicalDisplay.LayExplosivesAttackDialog.title", @@ -1738,6 +1833,13 @@ public void hexSelected(BoardViewEvent event) { } if (isMyTurn() && (event.getCoords() != null) && (currentEntity() != null)) { + // If we're selecting a hex for woods clearing, handle that instead of normal targeting + if (selectingClearWoodsHex) { + selectingClearWoodsHex = false; + completeClearWoods(event.getCoords(), event.getBoardId()); + return; + } + Targetable target = chooseTarget(event); target(target); } @@ -1919,6 +2021,8 @@ public void actionPerformed(ActionEvent ev) { proto(); } else if (ev.getActionCommand().equals(PhysicalCommand.PHYSICAL_EXPLOSIVES.getCmd())) { explosives(); + } else if (ev.getActionCommand().equals(PhysicalCommand.PHYSICAL_CLEAR_WOODS.getCmd())) { + clearWoods(); } else if (ev.getActionCommand().equals(PhysicalCommand.PHYSICAL_VIBRO.getCmd())) { vibroclawAttack(); } else if (ev.getActionCommand().equals(PhysicalCommand.PHYSICAL_PHEROMONE.getCmd())) { @@ -2036,6 +2140,10 @@ public void setExplosivesEnabled(boolean enabled) { // clientGUI.getMenuBar().setExplosivesEnabled(enabled); } + public void setClearWoodsEnabled(boolean enabled) { + buttons.get(PhysicalCommand.PHYSICAL_CLEAR_WOODS).setEnabled(enabled); + } + public void setNextEnabled(boolean enabled) { buttons.get(PhysicalCommand.PHYSICAL_NEXT).setEnabled(enabled); clientgui.getMenuBar().setEnabled(PhysicalCommand.PHYSICAL_NEXT.getCmd(), enabled); diff --git a/megamek/src/megamek/common/WoodsClearingTracker.java b/megamek/src/megamek/common/WoodsClearingTracker.java new file mode 100644 index 00000000000..8feaab2f698 --- /dev/null +++ b/megamek/src/megamek/common/WoodsClearingTracker.java @@ -0,0 +1,243 @@ +/* + * 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; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import megamek.common.board.BoardLocation; + +/** + * Tracks ongoing woods-clearing operations using chainsaws and dual saws. + * + *

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 Map clearingOperations = new HashMap<>(); + + /** + * Tracks the clearing state for a single hex. + */ + private static class ClearingState implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** Entity IDs that declared clearing this round. */ + final Set contributorsThisRound = new HashSet<>(); + + /** Entity IDs that declared clearing last round. */ + final Set contributorsLastRound = new HashSet<>(); + + /** Total work turns accumulated on this hex. */ + int accumulatedWork = 0; + } + + /** + * Registers an entity as clearing a specific hex this round. + * + * @param entityId the ID of the entity performing clearing + * @param targetHex the hex being cleared + */ + public void declareClearing(int entityId, BoardLocation targetHex) { + ClearingState state = clearingOperations.computeIfAbsent(targetHex, k -> new ClearingState()); + state.contributorsThisRound.add(entityId); + } + + /** + * Processes the round transition for all clearing operations. + * + *

For 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 List processNewRound() { + List completed = new ArrayList<>(); + Iterator> it = clearingOperations.entrySet().iterator(); + + while (it.hasNext()) { + Map.Entry entry = it.next(); + ClearingState state = entry.getValue(); + + if (state.contributorsThisRound.isEmpty()) { + // No one worked this hex this round - work persists but doesn't progress + state.contributorsLastRound.clear(); + } else { + // Increment work + state.accumulatedWork++; + + // Check for completion + boolean multipleContributors = state.contributorsThisRound.size() >= 2; + if (multipleContributors || state.accumulatedWork >= TURNS_REQUIRED_SINGLE) { + completed.add(entry.getKey()); + it.remove(); + } else { + // Shift current round contributors to last round for next cycle + state.contributorsLastRound.clear(); + state.contributorsLastRound.addAll(state.contributorsThisRound); + state.contributorsThisRound.clear(); + } + } + } + + return completed; + } + + /** + * Checks if an entity declared clearing in the previous round (used for firing penalty). + * + * @param entityId the entity ID to check + * + * @return true if the entity was clearing last round and should have the running/flank penalty + */ + public boolean wasClearingLastRound(int entityId) { + for (ClearingState state : clearingOperations.values()) { + if (state.contributorsLastRound.contains(entityId)) { + return true; + } + } + return false; + } + + /** + * Checks if a hex already has accumulated clearing work (partially cut). + * + * @param hex the hex to check + * + * @return true if the hex has any accumulated work from prior turns + */ + public boolean hasAccumulatedWork(BoardLocation hex) { + ClearingState state = clearingOperations.get(hex); + return state != null && state.accumulatedWork > 0; + } + + /** + * Checks if an entity is currently declared as clearing this round. + * + * @param entityId the entity ID to check + * + * @return true if the entity declared clearing this round + */ + public boolean isClearingThisRound(int entityId) { + for (ClearingState state : clearingOperations.values()) { + if (state.contributorsThisRound.contains(entityId)) { + return true; + } + } + return false; + } + + /** + * Returns the hex that a given entity is clearing this round, or null if not clearing. + * + * @param entityId the entity ID to check + * + * @return the BoardLocation being cleared, or null + */ + public BoardLocation getClearingTarget(int entityId) { + for (Map.Entry entry : clearingOperations.entrySet()) { + if (entry.getValue().contributorsThisRound.contains(entityId)) { + return entry.getKey(); + } + } + return null; + } + + /** + * Returns all entity IDs currently involved in clearing operations (this round or last round). + * + * @return set of entity IDs involved in clearing + */ + public Set getAllClearingEntities() { + Set entities = new HashSet<>(); + for (ClearingState state : clearingOperations.values()) { + entities.addAll(state.contributorsThisRound); + entities.addAll(state.contributorsLastRound); + } + return entities; + } + + /** + * Returns a map of hex locations to the number of turns remaining to complete clearing. Accounts for the number of + * contributors this round when calculating remaining turns. + * + * @return map of hex locations to turns remaining (1 or 2 typically) + */ + public Map getTurnsRemainingPerHex() { + Map result = new HashMap<>(); + for (Map.Entry entry : clearingOperations.entrySet()) { + ClearingState state = entry.getValue(); + int turnsNeeded = (state.contributorsThisRound.size() >= 2) + ? TURNS_REQUIRED_MULTI : TURNS_REQUIRED_SINGLE; + // Count current round's contribution toward displayed remaining turns + int effectiveWork = state.accumulatedWork; + if (!state.contributorsThisRound.isEmpty()) { + effectiveWork++; + } + int remaining = Math.max(0, turnsNeeded - effectiveWork); + result.put(entry.getKey(), remaining); + } + return result; + } + + /** + * Removes all clearing operations. Used when resetting the game state. + */ + public void clear() { + clearingOperations.clear(); + } +} diff --git a/megamek/src/megamek/common/actions/ChargeAttackAction.java b/megamek/src/megamek/common/actions/ChargeAttackAction.java index e2d2dead138..7b3fc284253 100644 --- a/megamek/src/megamek/common/actions/ChargeAttackAction.java +++ b/megamek/src/megamek/common/actions/ChargeAttackAction.java @@ -45,7 +45,8 @@ import megamek.common.board.Coords; import megamek.common.compute.Compute; import megamek.common.enums.MoveStepType; -import megamek.common.equipment.GunEmplacement; +import megamek.common.equipment.MiscType; +import megamek.common.equipment.enums.MiscTypeFlag; import megamek.common.game.Game; import megamek.common.interfaces.ILocationExposureStatus; import megamek.common.moves.MovePath; @@ -218,11 +219,13 @@ public ToHitData toHit(Game game, Targetable target, Coords src, int elevation, return new ToHitData(TargetRoll.IMPOSSIBLE, "Target is prone"); } } else if (te instanceof Infantry) { - // Can't charge infantry. - return new ToHitData(TargetRoll.IMPOSSIBLE, "Target is infantry"); + // Can't charge infantry unless vehicle has a front-mounted saw (TM pp.241-243) + if (!hasFrontMountedSaw(attackingEntity)) { + return new ToHitData(TargetRoll.IMPOSSIBLE, "Target is infantry"); + } } else if (te instanceof ProtoMek) { // Can't charge ProtoMeks. - return new ToHitData(TargetRoll.IMPOSSIBLE, "Target is ProtoM 0)) { toHit.setHitTable(ToHitData.HIT_PUNCH); } else if (attackingEntity.getHeight() < target.getHeight()) { @@ -556,6 +572,71 @@ public static int getDamageTakenBy(Entity entity, Entity target, boolean tacOps, / (effectiveTargetWeight + entity.getWeight())) / 10); } + /** + * Checks if the given entity is a vehicle with a front-mounted chainsaw or dual saw. + * + *

Per 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, 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.actions; + +import java.io.Serial; + +import megamek.client.ui.Messages; +import megamek.common.Hex; +import megamek.common.ToHitData; +import megamek.common.board.Coords; +import megamek.common.compute.Compute; +import megamek.common.compute.ComputeArc; +import megamek.common.equipment.MiscMounted; +import megamek.common.equipment.MiscType; +import megamek.common.equipment.enums.MiscTypeFlag; +import megamek.common.game.Game; +import megamek.common.rolls.TargetRoll; +import megamek.common.units.Entity; +import megamek.common.units.Mek; +import megamek.common.units.Targetable; +import megamek.common.units.Terrains; + +/** + * Represents a unit using a chainsaw or dual saw to clear woods from a hex. + * + *

Per 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 Hashtable> minefields = new Hashtable<>(); private final Vector vibraBombs = new Vector<>(); private final Vector empMines = new Vector<>(); + + /** Tracks ongoing woods clearing operations for chainsaws and dual saws. Serialized with game saves. */ + private WoodsClearingTracker woodsClearingTracker = new WoodsClearingTracker(); + + /** Hex locations being cleared by saws, mapped to turns remaining. For board view rendering. */ + private Map hexesBeingCut = new HashMap<>(); private Vector attacks = new Vector<>(); private Vector offboardArtilleryAttacks = new Vector<>(); private Vector orbitalBombardmentAttacks = new Vector<>(); @@ -270,6 +277,41 @@ public void addMinefield(Minefield mf) { processGameEvent(new GameBoardChangeEvent(this)); } + /** + * Returns the woods clearing tracker for this game. Serialized with game saves. + */ + public WoodsClearingTracker getWoodsClearingTracker() { + if (woodsClearingTracker == null) { + woodsClearingTracker = new WoodsClearingTracker(); + } + return woodsClearingTracker; + } + + /** + * Returns the map of hex locations being cleared by saws to turns remaining. + * Used by the board view to render cut indicators and tooltips. + */ + public Map getHexesBeingCut() { + if (hexesBeingCut == null) { + hexesBeingCut = new HashMap<>(); + } + return hexesBeingCut; + } + + /** + * Updates the map of hex locations being cleared by saws. + * + * @param hexes the current map of hexes being cut to turns remaining + */ + public void setHexesBeingCut(Map hexes) { + if ((hexes == null) || hexes.isEmpty()) { + hexesBeingCut = new HashMap<>(); + } else { + hexesBeingCut = new HashMap<>(hexes); + } + processGameEvent(new GameBoardChangeEvent(this)); + } + public void addMinefields(Vector mines) { for (int i = 0; i < mines.size(); i++) { Minefield mf = mines.elementAt(i); diff --git a/megamek/src/megamek/common/net/enums/PacketCommand.java b/megamek/src/megamek/common/net/enums/PacketCommand.java index fa9152de4ac..dd58316422f 100644 --- a/megamek/src/megamek/common/net/enums/PacketCommand.java +++ b/megamek/src/megamek/common/net/enums/PacketCommand.java @@ -205,7 +205,10 @@ public enum PacketCommand { ADD_TEMPORARY_ECM_FIELD, /** A packet syncing all temporary ECM fields to clients (replaces existing list). */ - SYNC_TEMPORARY_ECM_FIELDS; + SYNC_TEMPORARY_ECM_FIELDS, + + /** A packet updating hex locations being cleared by saws (for board view rendering). */ + UPDATE_CUT_HEXES; //endregion Enum Declarations //region Boolean Comparison Methods diff --git a/megamek/src/megamek/common/net/packets/Packet.java b/megamek/src/megamek/common/net/packets/Packet.java index 1738a71ae0c..95b15d4449c 100644 --- a/megamek/src/megamek/common/net/packets/Packet.java +++ b/megamek/src/megamek/common/net/packets/Packet.java @@ -399,6 +399,28 @@ public HashSet getCoordsHashSet(int index) throws InvalidPacketDataExcep throw new InvalidPacketDataException("HashSet", object, index); } + /** + * @param index the index of the desired object + * + * @return a HashMap of {@link BoardLocation} to Integer at the specified index + */ + @SuppressWarnings("unchecked") + public HashMap getBoardLocationIntegerMap(int index) throws InvalidPacketDataException { + Object object = getObject(index); + + if (object instanceof HashMap map) { + HashMap result = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof BoardLocation loc && entry.getValue() instanceof Integer val) { + result.put(loc, val); + } + } + return result; + } + + throw new InvalidPacketDataException("HashMap", object, index); + } + /** * @param index the index of the desired object * diff --git a/megamek/src/megamek/common/units/Entity.java b/megamek/src/megamek/common/units/Entity.java index fa1341bbf4f..27811a3616a 100644 --- a/megamek/src/megamek/common/units/Entity.java +++ b/megamek/src/megamek/common/units/Entity.java @@ -64,6 +64,7 @@ import megamek.common.actions.PushAttackAction; import megamek.common.actions.TeleMissileAttackAction; import megamek.common.actions.WeaponAttackAction; +import megamek.common.actions.WoodsClearingAttackAction; import megamek.common.annotations.Nullable; import megamek.common.battleArmor.BattleArmor; import megamek.common.battleArmor.BattleArmorHandles; @@ -486,6 +487,7 @@ public enum InvalidSourceBuildReason { public boolean spotting; private boolean clearingMinefield = false; + private boolean clearingWoods = false; protected int killerId = Entity.NONE; private int offBoardDistance = 0; private OffBoardDirection offBoardDirection = OffBoardDirection.NONE; @@ -5319,6 +5321,19 @@ public boolean hasWorkingMisc(EquipmentFlag flag, MiscTypeFlag secondaryFlag, in return false; } + /** + * Checks if this entity has a front-mounted chainsaw or dual saw. + * + *

Per 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 Vector getMainPhaseReport() { // canceling each other private final Vector physicalResults = new Vector<>(); + // Woods clearing tracker is stored on Game for serialization - access via game.getWoodsClearingTracker() + private final List terrainProcessors = new ArrayList<>(); private final ArrayList scheduledNukes = new ArrayList<>(); @@ -360,6 +362,12 @@ public void setGame(IGame game) { this.game.getForces().setGame(this.game); rebuildCombatTrackerFromEntityStates(infantryActionTracker); + + // Rebuild cut hex rendering state from the serialized tracker. + // Only update the Game object here - clients aren't connected yet during load. + // The packet will be sent when clients reconnect and receive the full game state. + Map cutHexes = this.game.getWoodsClearingTracker().getTurnsRemainingPerHex(); + this.game.setHexesBeingCut(cutHexes); } /** @@ -15323,6 +15331,28 @@ private void resolveChargeDamage(Entity ae, Entity te, ToHitData toHit, int dire damage = (int) Math.ceil(damage * 1.5); damageTaken = (int) Math.floor(damageTaken * 0.5); } + + // Front-mounted saw charge: override damage with flat saw value (TM pp.241-243) + boolean sawChargeVsInfantry = false; + if ((te != null) && ChargeAttackAction.hasFrontMountedSaw(ae)) { + damage = ChargeAttackAction.getSawChargeDamage(ae, te); + // Attacker still takes normal charge self-damage (damageTaken unchanged) + + // Override hit table for Meks: Kick Location for standing, full table for prone + if (te instanceof Mek) { + if (te.isProne()) { + toHit.setHitTable(ToHitData.HIT_NORMAL); + } else { + toHit.setHitTable(ToHitData.HIT_KICK); + } + } + + // vs infantry: damage applied as though from another infantry unit (no "in the open" doubling) + if (te.isConventionalInfantry()) { + sawChargeVsInfantry = true; + } + } + if (glancing) { // Glancing Blow rule doesn't state whether damage to attacker on charge // or DFA is halved as well, assume yes. TODO : Check with PM @@ -15502,6 +15532,10 @@ private void resolveChargeDamage(Entity ae, Entity te, ToHitData toHit, int dire if (bDirect) { hit.makeDirectBlow(directBlowCritMod); } + // Saw charge vs infantry: damage applied as from another infantry unit (no open-ground doubling) + if (sawChargeVsInfantry) { + hit.setIgnoreInfantryDoubleDamage(true); + } cluster = checkForSpikes(te, hit.getLocation(), cluster, ae, Mek.LOC_CENTER_TORSO); // PLAYTEST3 make lance deal 1 point internal to the first cluster if armor remained. ABA and @@ -16243,6 +16277,139 @@ private void cleanupCombat(InfantryActionTracker.InfantryAction combat, tracker.removeCombat(combat.targetId); } + /** + * Handle a woods clearing action using a chainsaw or dual saw. + * + *

Registers 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() { + List completed = game.getWoodsClearingTracker().processNewRound(); + + for (BoardLocation loc : completed) { + Hex hex = game.getBoard(loc.boardId()).getHex(loc.coords()); + if (hex == null) { + continue; + } + + // Reduce woods/jungle one level + boolean reduced = false; + if (hex.containsTerrain(Terrains.WOODS)) { + int level = hex.terrainLevel(Terrains.WOODS); + if (level > 1) { + // Heavy -> Light (or Ultra Heavy -> Heavy) + hex.removeTerrain(Terrains.WOODS); + hex.addTerrain(new Terrain(Terrains.WOODS, level - 1)); + reduced = true; + } else { + // Light -> Rough + hex.removeTerrain(Terrains.WOODS); + hex.removeTerrain(Terrains.FOLIAGE_ELEV); + hex.addTerrain(new Terrain(Terrains.ROUGH, 1)); + reduced = true; + } + } else if (hex.containsTerrain(Terrains.JUNGLE)) { + int level = hex.terrainLevel(Terrains.JUNGLE); + if (level > 1) { + hex.removeTerrain(Terrains.JUNGLE); + hex.addTerrain(new Terrain(Terrains.JUNGLE, level - 1)); + reduced = true; + } else { + hex.removeTerrain(Terrains.JUNGLE); + hex.removeTerrain(Terrains.FOLIAGE_ELEV); + hex.addTerrain(new Terrain(Terrains.ROUGH, 1)); + reduced = true; + } + } + + if (reduced) { + Report r = new Report(4502, Report.PUBLIC); + r.add(loc.coords().getBoardNum()); + addReport(r); + hexUpdateSet.add(loc); + } + } + + // Send hex changes to clients immediately so the map updates + if (!completed.isEmpty()) { + sendChangedHexes(); + } + + // Sync remaining clearing state to Game for board view rendering and send to clients + sendCutHexesUpdate(); + + } + + /** + * Sets the clearingWoods flag on entities that declared clearing last round. Called during initiative phase (before + * firing) so the firing penalty applies correctly. + */ + void applyClearingWoodsFlags() { + for (int entityId : game.getWoodsClearingTracker().getAllClearingEntities()) { + Entity entity = game.getEntity(entityId); + if (entity != null) { + entity.setClearingWoods(true); + } + } + } + private void resolveLayExplosivesAttack(PhysicalResult pr) { final LayExplosivesAttackAction laa = (LayExplosivesAttackAction) pr.aaa; final Entity ae = game.getEntity(laa.getEntityId()); @@ -27083,6 +27250,16 @@ public void sendChangedHexes() { send(new Packet(PacketCommand.CHANGE_HEXES, changedHexes)); } + /** + * Syncs the current map of hexes being cleared by saws to the server Game object and sends the update to all + * clients so their board views can render the indicators. + */ + private void sendCutHexesUpdate() { + Map cutHexes = game.getWoodsClearingTracker().getTurnsRemainingPerHex(); + game.setHexesBeingCut(cutHexes); + send(new Packet(PacketCommand.UPDATE_CUT_HEXES, new HashMap<>(cutHexes))); + } + /** * Creates a packet containing a vector of mines. */ @@ -28364,7 +28541,10 @@ private PhysicalResult preTreatPhysicalAttack(AbstractAttackAction aaa) { Entity target = (Entity) caa.getTarget(game); if (target != null) { - if (caa.getTarget(game) instanceof Entity) { + // Front-mounted saw charge uses flat damage (TM pp.241-243) + if (ChargeAttackAction.hasFrontMountedSaw(ae)) { + damage = ChargeAttackAction.getMaxSawChargeDamage(ae, target); + } else if (caa.getTarget(game) instanceof Entity) { damage = ChargeAttackAction.getDamageFor(ae, target, game.getOptions().booleanOption(OptionsConstants.ADVANCED_COMBAT_TAC_OPS_CHARGE_DAMAGE), @@ -28491,6 +28671,9 @@ private PhysicalResult preTreatPhysicalAttack(AbstractAttackAction aaa) { } else if (aaa instanceof SuicideImplantsAttackAction suicideImplantsAction) { toHit = suicideImplantsAction.toHit(game); damage = SuicideImplantsAttackAction.getDamageFor(suicideImplantsAction.getTroopersDetonating()); + } else if (aaa instanceof WoodsClearingAttackAction wca) { + toHit = wca.toHit(game); + damage = 0; // Woods clearing causes no direct damage } pr.toHit = toHit; pr.damage = damage; @@ -28555,6 +28738,9 @@ private void resolvePhysicalAttack(PhysicalResult pr, int cen) { } else if (aaa instanceof DfaAttackAction) { resolveDfaAttack(pr, cen); cen = aaa.getEntityId(); + } else if (aaa instanceof WoodsClearingAttackAction) { + resolveWoodsClearingAction(pr); + cen = aaa.getEntityId(); } else if (aaa instanceof LayExplosivesAttackAction) { resolveLayExplosivesAttack(pr); cen = aaa.getEntityId(); diff --git a/megamek/src/megamek/server/totalWarfare/TWPhaseEndManager.java b/megamek/src/megamek/server/totalWarfare/TWPhaseEndManager.java index d97a7336b16..29a142130b3 100644 --- a/megamek/src/megamek/server/totalWarfare/TWPhaseEndManager.java +++ b/megamek/src/megamek/server/totalWarfare/TWPhaseEndManager.java @@ -184,6 +184,9 @@ void managePhase() { case PHYSICAL: gameManager.resolveWhatPlayersCanSeeWhatUnits(); gameManager.resolvePhysicalAttacks(); + // Process woods clearing completions after all declarations are resolved. + // Per TW p.112, terrain converts immediately when threshold is met. + gameManager.processWoodsClearingCompletions(); gameManager.resolveBoobyTraps(); // booby trap says it resolves "immediately"... could be problematic gameManager.applyBuildingDamage(); gameManager.checkForPSRFromDamage(); diff --git a/megamek/src/megamek/server/totalWarfare/TWPhasePreparationManager.java b/megamek/src/megamek/server/totalWarfare/TWPhasePreparationManager.java index 424dc27d032..5f1c0e4629b 100644 --- a/megamek/src/megamek/server/totalWarfare/TWPhasePreparationManager.java +++ b/megamek/src/megamek/server/totalWarfare/TWPhasePreparationManager.java @@ -78,6 +78,9 @@ void managePhase() { gameManager.sendTagInfoReset(); gameManager.clearReports(); gameManager.resetEntityRound(); + // Set clearingWoods flag on entities that declared clearing last round + // (for firing penalty this round). Must be set before firing phase. + gameManager.applyClearingWoodsFlags(); gameManager.resetEntityPhase(phase); gameManager.checkForObservers(); gameManager.transmitAllPlayerUpdates(); diff --git a/megamek/unittests/megamek/common/WoodsClearingTrackerTest.java b/megamek/unittests/megamek/common/WoodsClearingTrackerTest.java new file mode 100644 index 00000000000..43c48889a28 --- /dev/null +++ b/megamek/unittests/megamek/common/WoodsClearingTrackerTest.java @@ -0,0 +1,285 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import megamek.common.board.BoardLocation; +import megamek.common.board.Coords; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link WoodsClearingTracker}. + * + *

Per 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); + List completed = tracker.processNewRound(); + assertTrue(completed.isEmpty(), "Should not complete after 1 turn with single saw"); + + // Turn 2: entity 1 declares clearing hex A again + tracker.declareClearing(1, hexA); + completed = tracker.processNewRound(); + assertEquals(1, completed.size(), "Should complete after 2 turns with single saw"); + assertEquals(hexA, completed.get(0)); + } + + @Test + @DisplayName("Clearing is not complete after only 1 turn") + void singleSawOneTurnNotComplete() { + tracker.declareClearing(1, hexA); + List completed = tracker.processNewRound(); + assertTrue(completed.isEmpty(), "Should not complete after only 1 turn"); + } + } + + @Nested + @DisplayName("Multiple Saws Clearing (1 turn)") + class MultipleSawTests { + + @Test + @DisplayName("Two saws on same hex complete in 1 turn") + void twoSawsOneTurn() { + // Turn 1: two entities declare clearing hex A + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexA); + List completed = tracker.processNewRound(); + assertEquals(1, completed.size(), "Two saws on same hex should complete in 1 turn"); + assertEquals(hexA, completed.get(0)); + } + + @Test + @DisplayName("Three saws on same hex also complete in 1 turn") + void threeSawsOneTurn() { + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexA); + tracker.declareClearing(3, hexA); + List completed = tracker.processNewRound(); + assertEquals(1, completed.size(), "Three saws should also complete in 1 turn"); + } + + @Test + @DisplayName("Two saws on different hexes do not combine") + void twoSawsDifferentHexes() { + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexB); + List completed = tracker.processNewRound(); + assertTrue(completed.isEmpty(), + "Two saws on different hexes should not complete either in 1 turn"); + } + } + + @Nested + @DisplayName("Work Accumulation and Persistence") + class WorkAccumulationTests { + + @Test + @DisplayName("Work persists when no entity clears for a round") + void workPersistsOnPause() { + // Turn 1: entity 1 declares clearing hex A + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + + // Turn 2: no one clears hex A (entity stopped) - work persists + List completed = tracker.processNewRound(); + assertTrue(completed.isEmpty(), "No completions when no one clears"); + assertTrue(tracker.hasAccumulatedWork(hexA), "Work should persist on the hex"); + + // Turn 3: entity 1 resumes - completes since 1 turn of work was already done + tracker.declareClearing(1, hexA); + completed = tracker.processNewRound(); + assertEquals(1, completed.size(), + "Should complete after resuming - prior work was preserved"); + } + + @Test + @DisplayName("Work persists across multiple idle rounds") + void workPersistsAcrossMultipleIdleRounds() { + // Turn 1: entity 1 declares clearing hex A + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + + // Turns 2-4: no one works the hex + tracker.processNewRound(); + tracker.processNewRound(); + tracker.processNewRound(); + assertTrue(tracker.hasAccumulatedWork(hexA), "Work should survive idle rounds"); + + // Turn 5: entity 1 resumes - completes + tracker.declareClearing(1, hexA); + List completed = tracker.processNewRound(); + assertEquals(1, completed.size(), + "Should complete after gap - accumulated work was not lost"); + } + + @Test + @DisplayName("Different entity can continue work on same hex") + void differentEntityContinuesWork() { + // Turn 1: entity 1 declares clearing hex A + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + + // Turn 2: entity 2 continues clearing hex A (entity 1 moved away) + tracker.declareClearing(2, hexA); + List completed = tracker.processNewRound(); + assertEquals(1, completed.size(), + "Different entity should be able to continue accumulated work"); + } + + @Test + @DisplayName("Multiple hexes can be cleared independently") + void multipleHexesIndependent() { + // Turn 1: clear both hexes + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexB); + tracker.processNewRound(); + + // Turn 2: continue both + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexB); + List completed = tracker.processNewRound(); + assertEquals(2, completed.size(), "Both hexes should complete independently"); + } + + @Test + @DisplayName("hasAccumulatedWork returns false for fresh hex") + void noAccumulatedWorkOnFreshHex() { + assertFalse(tracker.hasAccumulatedWork(hexA), "Fresh hex should have no work"); + } + + @Test + @DisplayName("hasAccumulatedWork returns true after one round of work") + void hasAccumulatedWorkAfterOneRound() { + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + assertTrue(tracker.hasAccumulatedWork(hexA), "Hex should have accumulated work"); + } + } + + @Nested + @DisplayName("Entity State Queries") + class EntityStateTests { + + @Test + @DisplayName("wasClearingLastRound returns true for entities that cleared last round") + void wasClearingLastRound() { + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + + // Entity 1 was clearing last round (now in tracker's lastRound set) + assertTrue(tracker.wasClearingLastRound(1), + "Entity 1 should be marked as clearing from last round"); + assertFalse(tracker.wasClearingLastRound(2), + "Entity 2 was not clearing"); + } + + @Test + @DisplayName("isClearingThisRound returns true for entities declared this round") + void isClearingThisRound() { + tracker.declareClearing(1, hexA); + assertTrue(tracker.isClearingThisRound(1)); + assertFalse(tracker.isClearingThisRound(2)); + } + + @Test + @DisplayName("getClearingTarget returns correct hex for entity") + void getClearingTarget() { + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexB); + + assertEquals(hexA, tracker.getClearingTarget(1)); + assertEquals(hexB, tracker.getClearingTarget(2)); + assertNull(tracker.getClearingTarget(3), "Entity not clearing should return null"); + } + + @Test + @DisplayName("getAllClearingEntities returns all entities involved") + void getAllClearingEntities() { + tracker.declareClearing(1, hexA); + tracker.processNewRound(); + tracker.declareClearing(2, hexA); + + var entities = tracker.getAllClearingEntities(); + assertTrue(entities.contains(1), "Entity 1 should be in last round set"); + assertTrue(entities.contains(2), "Entity 2 should be in this round set"); + } + } + + @Nested + @DisplayName("Clear and Reset") + class ClearTests { + + @Test + @DisplayName("clear() removes all operations") + void clearRemovesAll() { + tracker.declareClearing(1, hexA); + tracker.declareClearing(2, hexB); + tracker.clear(); + + assertFalse(tracker.isClearingThisRound(1)); + assertFalse(tracker.isClearingThisRound(2)); + assertTrue(tracker.getAllClearingEntities().isEmpty()); + } + } +} diff --git a/megamek/unittests/megamek/common/actions/SawChargeTest.java b/megamek/unittests/megamek/common/actions/SawChargeTest.java new file mode 100644 index 00000000000..7593f3ca0ec --- /dev/null +++ b/megamek/unittests/megamek/common/actions/SawChargeTest.java @@ -0,0 +1,262 @@ +/* + * 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.actions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import megamek.common.equipment.MiscType; +import megamek.common.equipment.enums.MiscTypeFlag; +import megamek.common.units.Entity; +import megamek.common.units.Infantry; +import megamek.common.units.Mek; +import megamek.common.units.Tank; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for vehicle saw charge mechanics per TM pp.241-243. + * + *

A 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, 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.actions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import megamek.common.Hex; +import megamek.common.ToHitData; +import megamek.common.board.Board; +import megamek.common.board.Coords; +import megamek.common.equipment.MiscMounted; +import megamek.common.equipment.MiscType; +import megamek.common.equipment.enums.MiscTypeFlag; +import megamek.common.game.Game; +import megamek.common.rolls.TargetRoll; +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.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link WoodsClearingAttackAction} validation logic. + * + *

Per 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; }