From d78edf56b7c7bce7a4ca68d64ef9b35feebdab4a Mon Sep 17 00:00:00 2001 From: HammerGS Date: Fri, 11 Sep 2026 22:14:58 -0600 Subject: [PATCH 1/5] Fix #8863: offer turret rotation on a point-blank shot Fixes #8863 Total Warfare p.260 lets a hidden unit torso twist or rotate its turret before a point-blank shot. Twist was wired up; rotation was not offered at all, so a turreted vehicle had to fire on whatever facing it hid with. PointblankShotDisplay declares its own FiringCommand enum and it had no rotate commands, which left rotateSelectedMount, rotateRearTurret and updateRotateTurret unreachable during a point-blank shot even though the shared AttackPhaseDisplay already implements all three. Adds FIRE_ROTATE_TURRET and FIRE_ROTATE_TURRET_2, the two setters and the label setter, and the action handling, all mirroring FiringDisplay. The rear-turret button is offered only for a dual-turret vehicle, as in the firing phase, and the buttons are refreshed when the unit is selected. Co-Authored-By: Claude Opus 5 (1M context) --- .../phaseDisplay/PointblankShotDisplay.java | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/PointblankShotDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/PointblankShotDisplay.java index 0f0e0109bec..23514b47dd9 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/PointblankShotDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/PointblankShotDisplay.java @@ -62,7 +62,6 @@ import megamek.common.compute.Compute; import megamek.common.compute.ComputeArc; import megamek.common.enums.AimingMode; -import megamek.common.enums.ChargeLevel; import megamek.common.enums.GamePhase; import megamek.common.equipment.AmmoType; import megamek.common.equipment.Mounted; @@ -73,6 +72,7 @@ import megamek.common.options.OptionsConstants; import megamek.common.rolls.TargetRoll; import megamek.common.units.Entity; +import megamek.common.units.Tank; import megamek.common.units.Targetable; import megamek.common.weapons.Weapon; import megamek.common.weapons.capitalWeapons.CapitalMissileWeapon; @@ -97,6 +97,8 @@ public class PointblankShotDisplay extends FiringDisplay { */ public enum FiringCommand implements PhaseCommand { FIRE_TWIST("fireTwist"), + FIRE_ROTATE_TURRET("fireRotateTurret"), + FIRE_ROTATE_TURRET_2("fireRotateTurret2"), FIRE_FIRE("fireFire"), FIRE_SKIP("fireSkip"), FIRE_MODE("fireMode"), @@ -310,6 +312,11 @@ protected ArrayList getButtonList() { || cmd == FiringCommand.FIRE_CANCEL) { continue; } + // The rear-turret rotate button exists only for dual-turret vehicles, as in the firing phase. + if ((cmd == FiringCommand.FIRE_ROTATE_TURRET_2) + && !((currentEntity() instanceof Tank tank) && !tank.hasNoDualTurret())) { + continue; + } buttonList.add(buttons.get(cmd)); i++; @@ -358,6 +365,8 @@ public void selectEntity(int en) { && currentEntity().getCrew().isActive()); setFlipArmsEnabled(currentEntity().canFlipArms()); + // TW p.260 lets a hidden unit torso twist OR rotate its turret before the point-blank shot. + updateRotateTurret(); updateSearchlight(); } else { logger.error("Tried to select non-existent entity {}", en); @@ -859,6 +868,10 @@ public void actionPerformed(ActionEvent ev) { nextWeapon(); } else if (ev.getActionCommand().equals(FiringCommand.FIRE_TWIST.getCmd())) { twisting = true; + } else if (ev.getActionCommand().equals(FiringCommand.FIRE_ROTATE_TURRET.getCmd())) { + rotateSelectedMount(); + } else if (ev.getActionCommand().equals(FiringCommand.FIRE_ROTATE_TURRET_2.getCmd())) { + rotateRearTurret(); } else if (ev.getActionCommand().equals(FiringCommand.FIRE_MORE.getCmd())) { currentButtonGroup++; currentButtonGroup %= numButtonGroups; @@ -887,6 +900,24 @@ protected void setTwistEnabled(boolean enabled) { clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_TWIST.getCmd(), enabled); } + @Override + protected void setRotateTurretLabel(boolean dualTurretTank) { + buttons.get(FiringCommand.FIRE_ROTATE_TURRET).setText(Messages.getString( + dualTurretTank ? "FiringDisplay.fireRotateTurretFront" : "FiringDisplay.fireRotateTurret")); + } + + @Override + protected void setRotateTurretEnabled(boolean enabled) { + buttons.get(FiringCommand.FIRE_ROTATE_TURRET).setEnabled(enabled); + clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_ROTATE_TURRET.getCmd(), enabled); + } + + @Override + protected void setRotateRearTurretEnabled(boolean enabled) { + buttons.get(FiringCommand.FIRE_ROTATE_TURRET_2).setEnabled(enabled); + clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_ROTATE_TURRET_2.getCmd(), enabled); + } + @Override protected void setSkipEnabled(boolean enabled) { buttons.get(FiringCommand.FIRE_SKIP).setEnabled(enabled); From dc6e1c06e5f21ea097edfc52dd57486acd533d42 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 12:25:03 -0600 Subject: [PATCH 2/5] Redraw the unit after a turret or mount rotation Rotating a turret changed the facing but left the old one on screen. Of the five rotation paths only the vehicle main turret refreshed, and only because it is declared as a twist. A Mek turret and a Directional Torso Mount send the facing to the server and nothing comes back, so the board was never told. Two gaps, one each side. The client now redraws the unit and its firing arc after the facing dialog closes, on every path. The server now echoes a mount facing change with entityUpdate, so other players see the turret move at all. Both are older than the point-blank work; the firing and targeting phases went through the same paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../phaseDisplay/AttackPhaseDisplay.java | 22 +++++++++++++++++++ .../server/totalWarfare/TWGameManager.java | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java index 3b0a7421d72..3da36579032 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java @@ -334,6 +334,7 @@ public void rotateSelectedMount() { new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::declareSecondaryFacing) .setVisible(true); } + refreshAfterRotation(); return; } WeaponMounted weapon = clientgui.getUnitDisplay().wPan.getSelectedWeapon(); @@ -348,6 +349,26 @@ public void rotateSelectedMount() { new TurretFacingDialog(clientgui.getFrame(), turretMek, turretItem, clientgui).setVisible(true); } } + refreshAfterRotation(); + } + + /** + * Redraws the unit and its firing arc after a turret or mount rotation. + * + *

Only one of the rotation paths refreshed the board on its own. A vehicle main turret is declared as a twist, + * which refreshes; a Mek turret and a Directional Torso Mount only send the new facing to the server, which + * applies it and echoes nothing, so the unit kept its old facing on screen until something else redrew it.

+ * + *

The dialog is modal, so this runs once the player has accepted or cancelled it. Redrawing after a cancel + * costs nothing.

+ */ + private void refreshAfterRotation() { + Entity entity = currentEntity(); + if (entity == null) { + return; + } + clientgui.onAllBoardViews(boardView -> boardView.redrawEntity(entity)); + clientgui.updateFiringArc(entity); } /** @@ -358,6 +379,7 @@ public void rotateRearTurret() { if ((currentEntity() instanceof Tank tank) && !tank.hasNoDualTurret()) { new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::declareSecondaryFacing) .setVisible(true); + refreshAfterRotation(); } } diff --git a/megamek/src/megamek/server/totalWarfare/TWGameManager.java b/megamek/src/megamek/server/totalWarfare/TWGameManager.java index bd758fe49cf..4223dc9f0a3 100644 --- a/megamek/src/megamek/server/totalWarfare/TWGameManager.java +++ b/megamek/src/megamek/server/totalWarfare/TWGameManager.java @@ -27510,6 +27510,10 @@ private void receiveEntityMountedFacingChange(Packet c, int connIndex) throws In } else { m.setFacing(facing); } + // Tell the clients. Without this the facing changed only on the server and on the client that sent it, + // so nobody else saw the turret move and the sender's own board kept the old facing until some other + // update happened to redraw the unit. + entityUpdate(entityId); } /** From 8d9487677f616003b9a54203a930a292eb8f48b7 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 15:32:47 -0600 Subject: [PATCH 3/5] Fix the point-blank shot: trigger, crash and stale ranges Found while testing the turret rotation this PR adds. Rotating the turret worked, but nothing the player relies on reflected it, and the shot could not reliably be reached at all. Trigger. A hidden unit only got its shot when the mover stopped next to it, so walking past did nothing. TW p.260 keys the shot on being revealed by enemy movement, and says the target may continue its move afterwards, which is only possible part way through a move. The rule now lives in Compute.revealsHiddenUnitForPointblankShot with tests. Crash. Pressing Fire threw a NullPointerException. PointblankShotDisplay has its own command set and button map, so the parent map is never filled; it overrides ten of the twenty-two button setters and inherits the rest, and any inherited one reached into a null map. All the setters now go through one null-safe helper. Ranges. The field of fire only followed the turret in the firing, targeting and offboard phases. A point-blank shot is aimed during the enemy's movement phase, so the arc was drawn from the hull facing and rotating the turret changed nothing. It now asks whether the player is aiming rather than which phase it is. Redraw. The facing dialog is not modal, so refreshing after setVisible ran before the player had chosen. It is now a callback on accept, and it rebuilds the weapon panel, which is what the arc is drawn from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../spriteHandler/FiringArcSpriteHandler.java | 20 +++- .../phaseDisplay/AttackPhaseDisplay.java | 29 +++++- .../ui/panels/phaseDisplay/FiringDisplay.java | 93 ++++++++++--------- .../src/megamek/common/compute/Compute.java | 26 ++++++ .../server/totalWarfare/MovePathHandler.java | 14 +-- .../unittests/megamek/common/ComputeTest.java | 38 ++++++++ 6 files changed, 163 insertions(+), 57 deletions(-) diff --git a/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java index d9e6e81d422..dd2f070bf08 100644 --- a/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java +++ b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java @@ -39,6 +39,7 @@ import megamek.client.ui.clientGUI.ClientGUI; import megamek.client.ui.clientGUI.GUIPreferences; +import megamek.client.ui.panels.phaseDisplay.PointblankShotDisplay; import megamek.client.ui.clientGUI.boardview.sprite.FieldOfFireSprite; import megamek.client.ui.clientGUI.boardview.sprite.TextMarkerSprite; import megamek.common.Hex; @@ -368,6 +369,23 @@ private void updateFacing(WeaponMounted weapon) { } } + /** + * Whether the player is currently aiming a weapon, so the field of fire should follow the real firing arc + * including torso twist and any turret or directional-mount rotation (issues #1040, #6518). + * + *

Asking the display rather than only the phase, because a hidden unit's point-blank shot is aimed during + * the enemy's movement phase (TW p.260). Testing the phase alone drew that arc from the hull facing and ignored + * the turret entirely, so rotating the turret changed the ranges shown not at all.

+ * + * @return {@code true} if a weapon is being aimed right now + */ + private boolean isPlayerAiming() { + return game.getPhase().isFiring() + || game.getPhase().isTargeting() + || game.getPhase().isOffboard() + || (clientGUI.getCurrentPanel() instanceof PointblankShotDisplay); + } + private void updateFacing(WeaponMounted weapon, int assumedFacing) { if (firingEntity == null) { return; @@ -375,7 +393,7 @@ private void updateFacing(WeaponMounted weapon, int assumedFacing) { // In the aiming phases (firing and targeting/TAG/offboard) the effective facing includes torso twist and any // turret or directional-mount rotation, so the field of fire matches the real firing arc (issues #1040, #6518). // Other phases (e.g. the movement field-of-fire preview) use the base facing. - if (game.getPhase().isFiring() || game.getPhase().isTargeting() || game.getPhase().isOffboard()) { + if (isPlayerAiming()) { facing = TurretFacing.weaponFacing(firingEntity, firingEntity.getEquipmentNum(weapon)); } else { facing = firingEntity.getFacing(); diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java index 3da36579032..f3d6ae43b1c 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java @@ -331,10 +331,9 @@ public void rotateSelectedMount() { } else if (!tank.hasNoTurret()) { // The main turret follows the unit's secondary facing, so rotating it is a turret twist: the dialog // only picks the facing and the twist is declared through the same path as the Twist button. - new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::declareSecondaryFacing) + new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::rotateMainTurretTo) .setVisible(true); } - refreshAfterRotation(); return; } WeaponMounted weapon = clientgui.getUnitDisplay().wPan.getSelectedWeapon(); @@ -349,7 +348,6 @@ public void rotateSelectedMount() { new TurretFacingDialog(clientgui.getFrame(), turretMek, turretItem, clientgui).setVisible(true); } } - refreshAfterRotation(); } /** @@ -362,12 +360,34 @@ public void rotateSelectedMount() { *

The dialog is modal, so this runs once the player has accepted or cancelled it. Redrawing after a cancel * costs nothing.

*/ + /** + * Declares the main turret's new facing and redraws the unit. Called back by the facing dialog when the player + * accepts it. + * + *

The dialog is not modal, so {@code setVisible} returns as soon as it is on screen. Refreshing there ran + * before the player had chosen anything, which is why the board kept the old facing.

+ * + * @param facing the absolute facing (0-5) the player picked + */ + private void rotateMainTurretTo(int facing) { + declareSecondaryFacing(facing); + refreshAfterRotation(); + } + private void refreshAfterRotation() { Entity entity = currentEntity(); if (entity == null) { return; } clientgui.onAllBoardViews(boardView -> boardView.redrawEntity(entity)); + // The arc is drawn for whatever weapon the unit display currently shows, so it only picks up the new + // facing when the weapon panel is rebuilt. Reselecting the same weapon keeps the player's choice, which a + // full refresh would drop back to the first weapon. Same sequence the flip-mount button uses. + WeaponMounted selectedWeapon = clientgui.getUnitDisplay().wPan.getSelectedWeapon(); + clientgui.getUnitDisplay().wPan.displayMek(entity); + if (selectedWeapon != null) { + clientgui.getUnitDisplay().wPan.selectWeapon(selectedWeapon); + } clientgui.updateFiringArc(entity); } @@ -377,9 +397,8 @@ private void refreshAfterRotation() { */ public void rotateRearTurret() { if ((currentEntity() instanceof Tank tank) && !tank.hasNoDualTurret()) { - new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::declareSecondaryFacing) + new TurretFacingDialog(clientgui.getFrame(), tank, clientgui, this::rotateMainTurretTo) .setVisible(true); - refreshAfterRotation(); } } diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/FiringDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/FiringDisplay.java index 451f12edd49..1e48a60e27a 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/FiringDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/FiringDisplay.java @@ -2466,9 +2466,29 @@ private void doToggleRHS() { updateRHS(); } + /** + * Enables or disables one firing button and its menu item. + * + *

The button may be absent. {@link PointblankShotDisplay} declares its own command set and its own button + * map, so this map is never filled in for it; it overrides the setters for the buttons it has and inherits the + * rest, which have no button to enable. Reaching straight into the map threw instead: firing a point-blank shot + * crashed on the Fire button, because disabling the buttons ran through a command that display does not carry.

+ * + * @param command the button to change + * @param enabled whether it should be usable + */ + private void enableFiringButton(FiringCommand command, boolean enabled) { + if (buttons != null) { + MegaMekButton button = buttons.get(command); + if (button != null) { + button.setEnabled(enabled); + } + } + clientgui.getMenuBar().setEnabled(command.getCmd(), enabled); + } + protected void setFireEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_FIRE).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_FIRE.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_FIRE, enabled); } /** @@ -2484,50 +2504,46 @@ protected void setFireEnabled(boolean enabled) { * @return {@code true} if firing the selected weapon is currently allowed */ public boolean isFireAllowed() { - return buttons.get(FiringCommand.FIRE_FIRE).isEnabled(); + if (buttons == null) { + return false; + } + MegaMekButton fireButton = buttons.get(FiringCommand.FIRE_FIRE); + return (fireButton != null) && fireButton.isEnabled(); } protected void setTwistEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_TWIST).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_TWIST.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_TWIST, enabled); } protected void setSkipEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_SKIP).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_SKIP.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_SKIP, enabled); } protected void setFindClubEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_FIND_CLUB).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_FIND_CLUB.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_FIND_CLUB, enabled); } protected void setNextTargetEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_NEXT_TARG).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_NEXT_TARG.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_NEXT_TARG, enabled); } protected void setFlipArmsEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_FLIP_ARMS).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_FLIP_ARMS.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_FLIP_ARMS, enabled); } @Override protected void setFlipMountEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_FLIP_MOUNT).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_FLIP_MOUNT.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_FLIP_MOUNT, enabled); } @Override protected void setRotateTurretEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_ROTATE_TURRET).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_ROTATE_TURRET.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_ROTATE_TURRET, enabled); } @Override protected void setRotateRearTurretEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_ROTATE_TURRET_2).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_ROTATE_TURRET_2.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_ROTATE_TURRET_2, enabled); } @Override @@ -2537,23 +2553,19 @@ protected void setRotateTurretLabel(boolean dualTurretTank) { } protected void setSpotEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_SPOT).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_SPOT.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_SPOT, enabled); } protected void setSearchlightEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_SEARCHLIGHT).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_SEARCHLIGHT.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_SEARCHLIGHT, enabled); } protected void setFireModeEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_MODE).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_MODE.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_MODE, enabled); } protected void setFireChargeLevelEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_CHARGE).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_CHARGE.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_CHARGE, enabled); } /** @@ -2590,48 +2602,39 @@ private void updateFireModeTooltip(Mounted weapon) { } protected void setFireCalledEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_CALLED).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_CALLED.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_CALLED, enabled); } protected void setFireClearTurretEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_CLEAR_TURRET).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_CLEAR_TURRET.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_CLEAR_TURRET, enabled); } protected void setFireClearWeaponJamEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_CLEAR_WEAPON).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_CLEAR_WEAPON.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_CLEAR_WEAPON, enabled); } protected void setFireExtinguishEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_EXTINGUISH).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_EXTINGUISH.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_EXTINGUISH, enabled); } protected void setStrafeEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_STRAFE).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_STRAFE.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_STRAFE, enabled); } protected void setNextEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_NEXT).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_NEXT.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_NEXT, enabled); } protected void setActivateSPAEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_ACTIVATE_SPA).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_ACTIVATE_SPA.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_ACTIVATE_SPA, enabled); } protected void setRHSEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_RHS).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_RHS.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_RHS, enabled); } protected void setSuicideImplantsEnabled(boolean enabled) { - buttons.get(FiringCommand.FIRE_SUICIDE_IMPLANTS).setEnabled(enabled); - clientgui.getMenuBar().setEnabled(FiringCommand.FIRE_SUICIDE_IMPLANTS.getCmd(), enabled); + enableFiringButton(FiringCommand.FIRE_SUICIDE_IMPLANTS, enabled); } @Override diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index 8c5b07f5071..1d0ef930756 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -7894,6 +7894,32 @@ public static boolean canPointBlankShot(Entity attacker, Entity target) { * * @return true if detector can detect a unit in this situation */ + /** + * Whether a moving enemy reveals a hidden unit in a way that lets it take a point-blank shot (TW p.260). + * + *

A hidden unit revealed by enemy movement may immediately make the shot, and the rule allows the target to + * "continue its move after the attack" when it has MP left. That is only possible part way through a move, so a + * ground unit reveals as it passes rather than only when it stops. Requiring the mover to stop meant walking + * past a hidden unit did nothing at all.

+ * + *

An airborne mover is different: it reveals what it flies over, so the range depends on whether it carries + * an Active Probe.

+ * + * @param mover the unit that is moving + * @param distance hexes between the mover's current step and the hidden unit + * + * @return {@code true} if the hidden unit is revealed and may take its shot + */ + public static boolean revealsHiddenUnitForPointblankShot(Entity mover, int distance) { + if (distance > 1) { + return false; + } + if (!mover.isAirborne()) { + return true; + } + return distance == ((mover.getBAPRange() > 0) ? 1 : 0); + } + public static boolean canDetectHidden(Entity detector, int distance, boolean endStep) { // Ending movement adjacent to a hidden unit also reveals it. if (detector.isAerospace()) { diff --git a/megamek/src/megamek/server/totalWarfare/MovePathHandler.java b/megamek/src/megamek/server/totalWarfare/MovePathHandler.java index 0095dec453f..7b3abaf5985 100644 --- a/megamek/src/megamek/server/totalWarfare/MovePathHandler.java +++ b/megamek/src/megamek/server/totalWarfare/MovePathHandler.java @@ -2195,15 +2195,17 @@ private void processSteps() { // Potential point-blank shot when not causing stacking violation, but only in some situations: - // 1. mover is ground unit _and_ ends its movement adjacent to / in the hidden unit's hex; + // 1. mover is a ground unit and moves adjacent to / into the hidden unit's hex; // 2. mover is Aerospace and hidden unit is within detection range of its flight path // (with or without Active Probe). // and the revealed hidden unit has not already made a pointblank shot this turn. - } else if ( - (dist <= 1) && !hiddenEntity.madePointblankShot() && - ((!this.entity.isAirborne() && md.isEndStep(step)) || - (this.entity.isAirborne() && (dist == ((this.entity.getBAPRange() > 0) ? 1 : 0)))) - ) { + // + // The ground case deliberately does not wait for the end of the move. TW: a hidden unit + // revealed by enemy movement may immediately make the shot, and the target "may continue + // its move after the attack" if it has MP left - which can only happen part way through a + // move. Requiring the mover to stop meant walking past a hidden unit did nothing at all. + } else if (!hiddenEntity.madePointblankShot() + && Compute.revealsHiddenUnitForPointblankShot(this.entity, dist)) { // Hidden unit should always be revealed as the PBS trigger _is_ getting revealed. hiddenEntity.setHidden(false); diff --git a/megamek/unittests/megamek/common/ComputeTest.java b/megamek/unittests/megamek/common/ComputeTest.java index cdda63c34c0..53974603641 100644 --- a/megamek/unittests/megamek/common/ComputeTest.java +++ b/megamek/unittests/megamek/common/ComputeTest.java @@ -1091,4 +1091,42 @@ void mekInNonBuildingHexIsNotInBuilding() { assertFalse(Compute.isInBuilding(getGame(), mek)); } } + + @Test + void groundMoverRevealsAHiddenUnitAsItPasses() { + // TW p.260: the shot follows from being revealed by enemy movement, and the target may continue its move + // afterwards, which is only possible part way through a move. Walking past used to reveal nothing. + Entity groundMover = mock(Entity.class); + when(groundMover.isAirborne()).thenReturn(false); + + assertTrue(Compute.revealsHiddenUnitForPointblankShot(groundMover, 0), + "moving into the hidden unit's own hex reveals it"); + assertTrue(Compute.revealsHiddenUnitForPointblankShot(groundMover, 1), + "moving adjacent reveals it, whether or not the mover stops there"); + assertFalse(Compute.revealsHiddenUnitForPointblankShot(groundMover, 2), + "two hexes away is out of reach"); + } + + @Test + void airborneMoverRevealsOnlyWhatItFliesOverWithoutAProbe() { + Entity flyer = mock(Entity.class); + when(flyer.isAirborne()).thenReturn(true); + when(flyer.getBAPRange()).thenReturn(0); + + assertTrue(Compute.revealsHiddenUnitForPointblankShot(flyer, 0), "it reveals what it overflies"); + assertFalse(Compute.revealsHiddenUnitForPointblankShot(flyer, 1), + "without an Active Probe an adjacent hex is not revealed"); + } + + @Test + void airborneMoverWithAProbeRevealsAnAdjacentHex() { + Entity flyerWithProbe = mock(Entity.class); + when(flyerWithProbe.isAirborne()).thenReturn(true); + when(flyerWithProbe.getBAPRange()).thenReturn(4); + + assertTrue(Compute.revealsHiddenUnitForPointblankShot(flyerWithProbe, 1), + "an Active Probe extends the reveal to an adjacent hex"); + assertFalse(Compute.revealsHiddenUnitForPointblankShot(flyerWithProbe, 0), + "with a probe the reveal is the adjacent hex, not the overflown one"); + } } From 866284b52862fdc20460db90e1af883c5aed42ea Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 15:40:24 -0600 Subject: [PATCH 4/5] Apply spotless formatting Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../boardview/spriteHandler/FiringArcSpriteHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java index dd2f070bf08..c42dfba7c97 100644 --- a/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java +++ b/megamek/src/megamek/client/ui/clientGUI/boardview/spriteHandler/FiringArcSpriteHandler.java @@ -39,9 +39,9 @@ import megamek.client.ui.clientGUI.ClientGUI; import megamek.client.ui.clientGUI.GUIPreferences; -import megamek.client.ui.panels.phaseDisplay.PointblankShotDisplay; import megamek.client.ui.clientGUI.boardview.sprite.FieldOfFireSprite; import megamek.client.ui.clientGUI.boardview.sprite.TextMarkerSprite; +import megamek.client.ui.panels.phaseDisplay.PointblankShotDisplay; import megamek.common.Hex; import megamek.common.HexTarget; import megamek.common.RangeType; From 90522067c702b7801f5c886e25c01ea5a1330ca5 Mon Sep 17 00:00:00 2001 From: HammerGS Date: Sat, 12 Sep 2026 16:00:25 -0600 Subject: [PATCH 5/5] Address review: put two javadoc blocks back on their methods Inserting rotateMainTurretTo and revealsHiddenUnitForPointblankShot above existing methods left each of those methods' javadoc stranded on the new one. refreshAfterRotation and canDetectHidden had none. The stranded block also still claimed the facing dialog is modal, which was the wrong reading this branch already corrected. Dropped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y1Lb2sbXg7jYcZoGDgyhWB --- .../phaseDisplay/AttackPhaseDisplay.java | 17 +++++++---------- .../src/megamek/common/compute/Compute.java | 18 +++++++++--------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java index f3d6ae43b1c..f04d2b207b0 100644 --- a/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java +++ b/megamek/src/megamek/client/ui/panels/phaseDisplay/AttackPhaseDisplay.java @@ -350,16 +350,6 @@ public void rotateSelectedMount() { } } - /** - * Redraws the unit and its firing arc after a turret or mount rotation. - * - *

Only one of the rotation paths refreshed the board on its own. A vehicle main turret is declared as a twist, - * which refreshes; a Mek turret and a Directional Torso Mount only send the new facing to the server, which - * applies it and echoes nothing, so the unit kept its old facing on screen until something else redrew it.

- * - *

The dialog is modal, so this runs once the player has accepted or cancelled it. Redrawing after a cancel - * costs nothing.

- */ /** * Declares the main turret's new facing and redraws the unit. Called back by the facing dialog when the player * accepts it. @@ -374,6 +364,13 @@ private void rotateMainTurretTo(int facing) { refreshAfterRotation(); } + /** + * Redraws the unit and its firing arc after a turret or mount rotation. + * + *

Only one of the rotation paths refreshed the board on its own. A vehicle main turret is declared as a twist, + * which refreshes; a Mek turret and a Directional Torso Mount only send the new facing to the server, which + * applies it and echoes nothing, so the unit kept its old facing on screen until something else redrew it.

+ */ private void refreshAfterRotation() { Entity entity = currentEntity(); if (entity == null) { diff --git a/megamek/src/megamek/common/compute/Compute.java b/megamek/src/megamek/common/compute/Compute.java index 1d0ef930756..5c6a1572ac8 100644 --- a/megamek/src/megamek/common/compute/Compute.java +++ b/megamek/src/megamek/common/compute/Compute.java @@ -7885,15 +7885,6 @@ public static boolean canPointBlankShot(Entity attacker, Entity target) { return true; } - /** - * Lightweight helper for some step evaluation. No side effects. - * - * @param detector Entity that will detect a hidden unit - * @param distance int Distance from detector to hidden entity - * @param endStep boolean whether this detection is occurring at the last step of a move path - * - * @return true if detector can detect a unit in this situation - */ /** * Whether a moving enemy reveals a hidden unit in a way that lets it take a point-blank shot (TW p.260). * @@ -7920,6 +7911,15 @@ public static boolean revealsHiddenUnitForPointblankShot(Entity mover, int dista return distance == ((mover.getBAPRange() > 0) ? 1 : 0); } + /** + * Lightweight helper for some step evaluation. No side effects. + * + * @param detector Entity that will detect a hidden unit + * @param distance int Distance from detector to hidden entity + * @param endStep boolean whether this detection is occurring at the last step of a move path + * + * @return true if detector can detect a unit in this situation + */ public static boolean canDetectHidden(Entity detector, int distance, boolean endStep) { // Ending movement adjacent to a hidden unit also reveals it. if (detector.isAerospace()) {