diff --git a/megamek/i18n/megamek/common/options/messages.properties b/megamek/i18n/megamek/common/options/messages.properties index 8014d20732c..602d80c015e 100644 --- a/megamek/i18n/megamek/common/options/messages.properties +++ b/megamek/i18n/megamek/common/options/messages.properties @@ -440,7 +440,7 @@ GameOptionsInfo.option.advanced_movement.description=Newtonian physics-style mov GameOptionsInfo.option.heat_by_bay.displayableName=StratOps Advanced Heat GameOptionsInfo.option.heat_by_bay.description=Heat is produced by individual weapons bays rather than arcs GameOptionsInfo.option.atmospheric_control.displayableName=StratOps Advanced Atmospheric Control Rolls -GameOptionsInfo.option.atmospheric_control.description=atmospheric control rolls only made for damage above the damage threshold +GameOptionsInfo.option.atmospheric_control.description=Atmospheric control rolls occur where the total damage taken by a unit in a turn exceeds its highest damage threshold, or where one damage grouping exceeds the threshold of the target location. GameOptionsInfo.option.ammo_explosions.displayableName=StratOps Ammo explosions GameOptionsInfo.option.ammo_explosions.description=Roll 2D6 on critical hits to ammo-fed weapons. On a roll of 10 or higher, the ammunition explodes. GameOptionsInfo.option.stratops_aa_fire.displayableName=(Unofficial) Advanced Anti-Aircraft @@ -489,6 +489,8 @@ GameOptionsInfo.option.crashed_dropships_survive.displayableName=(Unofficial) Un GameOptionsInfo.option.crashed_dropships_survive.description=If checked, DropShips (and larger) that crash while out of control are not instantly destroyed. GameOptionsInfo.option.expanded_kf_drive_damage.displayableName=(Unofficial) Damage individual K-F Drive components on K-F Drive Critical Hit GameOptionsInfo.option.expanded_kf_drive_damage.description=If a K-F Drive critical hit is taken, a random component is hit (per BattleSpace rules). +GameOptionsInfo.option.unoff_adv_atmospheric_control.displayableName=(Unofficial) Old StratOps Advanced Atmospheric Control Rolls +GameOptionsInfo.option.unoff_adv_atmospheric_control.description=Atmospheric control rolls only apply to hit damage above the damage threshold GameOptionsInfo.group.initiative.displayableName=Initiative Rules diff --git a/megamek/src/megamek/common/Aero.java b/megamek/src/megamek/common/Aero.java index a27d2b4880c..e1525ad97d5 100644 --- a/megamek/src/megamek/common/Aero.java +++ b/megamek/src/megamek/common/Aero.java @@ -1609,6 +1609,17 @@ public int getThresh(int loc) { return 0; } + @Override + public int getHighestThresh() { + int max = damThresh[0]; + for (int i = 1; i < damThresh.length; i++) { + if (damThresh[i] > max) { + max = damThresh[i]; + } + } + return max; + } + /** * Determine if the unit can be repaired, or only harvested for spares. * diff --git a/megamek/src/megamek/common/IAero.java b/megamek/src/megamek/common/IAero.java index 9060caac715..f69fb6fd0ab 100644 --- a/megamek/src/megamek/common/IAero.java +++ b/megamek/src/megamek/common/IAero.java @@ -133,6 +133,8 @@ default int getClusterMods() { int getThresh(int loc); + int getHighestThresh(); + boolean wasCritThresh(); void setCritThresh(boolean b); diff --git a/megamek/src/megamek/common/LandAirMech.java b/megamek/src/megamek/common/LandAirMech.java index 6545c20e53d..c9a495b2a31 100644 --- a/megamek/src/megamek/common/LandAirMech.java +++ b/megamek/src/megamek/common/LandAirMech.java @@ -1692,6 +1692,20 @@ public int getThresh(int loc) { return getInternal(loc); } + /** + * @return the highest damage threshold for the LAM unit + */ + @Override + public int getHighestThresh() { + int max = getThresh(0); + for (int i = 1; i < locations(); i++) { + if (getThresh(i) > max) { + max = getThresh(i); + } + } + return max; + } + @Override public boolean wasCritThresh() { return critThresh; diff --git a/megamek/src/megamek/common/options/GameOptions.java b/megamek/src/megamek/common/options/GameOptions.java index ddbd9563a98..fc9740afbe7 100755 --- a/megamek/src/megamek/common/options/GameOptions.java +++ b/megamek/src/megamek/common/options/GameOptions.java @@ -269,6 +269,7 @@ public synchronized void initialize() { addOption(advAeroRules, OptionsConstants.ADVAERORULES_AERO_ARTILLERY_MUNITIONS, false); addOption(advAeroRules, OptionsConstants.ADVAERORULES_CRASHED_DROPSHIPS_SURVIVE, false); addOption(advAeroRules, OptionsConstants.ADVAERORULES_EXPANDED_KF_DRIVE_DAMAGE, false); + addOption(advAeroRules, OptionsConstants.UNOFF_ADV_ATMOSPHERIC_CONTROL, false); IBasicOptionGroup initiative = addGroup("initiative"); addOption(initiative, OptionsConstants.INIT_INF_MOVE_EVEN, false); diff --git a/megamek/src/megamek/common/options/OptionsConstants.java b/megamek/src/megamek/common/options/OptionsConstants.java index 2b44b7cd0ed..ccdd72b4606 100644 --- a/megamek/src/megamek/common/options/OptionsConstants.java +++ b/megamek/src/megamek/common/options/OptionsConstants.java @@ -218,6 +218,7 @@ public class OptionsConstants { public static final String UNOFF_BLIND_FIGHTER = "blind_fighter"; public static final String UNOFF_SENSOR_GEEK = "sensor_geek"; public static final String UNOFF_SMALL_PILOT = "small_pilot"; + public static final String UNOFF_ADV_ATMOSPHERIC_CONTROL = "unoff_adv_atmospheric_control"; // EDGE public static final String EDGE = "edge"; diff --git a/megamek/src/megamek/server/totalwarfare/TWGameManager.java b/megamek/src/megamek/server/totalwarfare/TWGameManager.java index b2be9d72255..0688d687076 100644 --- a/megamek/src/megamek/server/totalwarfare/TWGameManager.java +++ b/megamek/src/megamek/server/totalwarfare/TWGameManager.java @@ -58,6 +58,7 @@ import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Manages the Game and processes player actions. @@ -16057,9 +16058,10 @@ void checkForPSRFromDamage() { } } if (entity.isAero() && entity.isAirborne() && !game.getBoard().inSpace()) { - // if this aero has any damage, add another roll to the list. + // check if aero unit meets conditions for control rolls and add to the list if (entity.damageThisPhase > 0) { - if (!getGame().getOptions().booleanOption(OptionsConstants.ADVAERORULES_ATMOSPHERIC_CONTROL)) { + if (!getGame().getOptions().booleanOption(OptionsConstants.ADVAERORULES_ATMOSPHERIC_CONTROL) + && !getGame().getOptions().booleanOption(OptionsConstants.UNOFF_ADV_ATMOSPHERIC_CONTROL)) { int damMod = entity.damageThisPhase / 20; PilotingRollData damPRD = new PilotingRollData(entity.getId(), damMod, entity.damageThisPhase + " damage +" + damMod); @@ -16069,13 +16071,24 @@ void checkForPSRFromDamage() { } getGame().addControlRoll(damPRD); } else { - // was the damage threshold exceeded this round? - if (((IAero) entity).wasCritThresh()) { + // was the damage threshold in a single location exceeded this round? + if ((((IAero) entity).wasCritThresh())) { PilotingRollData damThresh = new PilotingRollData(entity.getId(), 0, - "damage threshold exceeded"); + "damage threshold exceeded"); if (entity.hasQuirk(OptionsConstants.QUIRK_POS_EASY_PILOT) - && (entity.getCrew().getPiloting() > 3)) { - damThresh.addModifier(-1, "easy to pilot"); + && (entity.getCrew().getPiloting() > 3)) { + damThresh.addModifier(-1, "easy to pilot"); + } + getGame().addControlRoll(damThresh); + } + if (getGame().getOptions().booleanOption(OptionsConstants.ADVAERORULES_ATMOSPHERIC_CONTROL) + && entity.damageThisPhase > ((IAero) entity).getHighestThresh()) { + // did the total damage this round exceed the unit's highest threshold? + PilotingRollData damThresh = new PilotingRollData(entity.getId(), 0, + "highest damage threshold exceeded"); + if (entity.hasQuirk(OptionsConstants.QUIRK_POS_EASY_PILOT) + && (entity.getCrew().getPiloting() > 3)) { + damThresh.addModifier(-1, "easy to pilot"); } getGame().addControlRoll(damThresh); } @@ -16813,18 +16826,22 @@ private Vector resolveControl(Entity e) { if (e.isUsingManAce()) { target.addModifier(-1, "maneuvering ace"); } - for (Enumeration j = game.getControlRolls(); j.hasMoreElements(); ) { - final PilotingRollData modifier = j.nextElement(); - if (modifier.getEntityId() != e.getId()) { - continue; - } - // found a roll, add it - rolls.addElement(modifier); - if (reasons.length() > 0) { - reasons.append("; "); - } - reasons.append(modifier.getCumulativePlainDesc()); - target.append(modifier); + if (game.getOptions().booleanOption(OptionsConstants.ADVAERORULES_ATMOSPHERIC_CONTROL)) { + addControlWithAdvAtmospheric(e, rolls, reasons); + } else { + for (Enumeration j = game.getControlRolls(); j.hasMoreElements(); ) { + final PilotingRollData modifier = j.nextElement(); + if (modifier.getEntityId() != e.getId()) { + continue; + } + // found a roll, add it + rolls.addElement(modifier); + if (!reasons.isEmpty()) { + reasons.append("; "); + } + reasons.append(modifier.getCumulativePlainDesc()); + target.append(modifier); + } } // any rolls needed? if (!rolls.isEmpty()) { @@ -16990,6 +17007,54 @@ private Vector resolveControl(Entity e) { return vReport; } + /** + * Processes pending control roll events, merging triggers that would be multiple rolls in standard + * rules but are combined into a single roll under the Advanced Atmospheric Control Rolls rule. + * + * @param e The entity whose pending rolls are being processed + * @param rolls List of rolls to be added to + * @param reasons Text representing the reason for the rolls + */ + void addControlWithAdvAtmospheric(Entity e, Vector rolls, StringBuilder reasons) { + // The August 2024 errata changed this rule to only trigger one control roll per + // round regardless of how many crits or thresholds occurred. As a result, the rolls + // need to be combined here at the end phase because not all the information is known + // when the individual rolls are first added throughout the round and previously added + // rolls can't easily be modified. + PilotingRollData atmosphericControlRoll = null; + for (Enumeration j = game.getControlRolls(); j.hasMoreElements(); ) { + final PilotingRollData modifier = j.nextElement(); + if (modifier.getEntityId() != e.getId()) { + continue; + } + if (Stream.of("threshold", "avionics hit", "critical hit").anyMatch(s -> modifier.getDesc().contains(s))) { + if (atmosphericControlRoll == null) { + atmosphericControlRoll = modifier; + } else { + // Modify the description of the pending atmospheric control roll instead of adding another one + if (!reasons.isEmpty()) { + reasons.append("; "); + } + reasons.append(modifier.getPlainDesc()); + } + } else { + // Not an atmospheric control roll under the new rules, so treat normally + rolls.addElement(modifier); + if (!reasons.isEmpty()) { + reasons.append("; "); + } + reasons.append(modifier.getPlainDesc()); + } + } + if (atmosphericControlRoll != null) { + rolls.addElement(atmosphericControlRoll); + if (!reasons.isEmpty()) { + reasons.append("; "); + } + reasons.append(atmosphericControlRoll.getPlainDesc()); + } + } + /** * Check all aircraft that may have used internal bomb bays for incidental explosions * caused by ground fire. diff --git a/megamek/unittests/megamek/server/totalwarfare/TWGameManagerTest.java b/megamek/unittests/megamek/server/totalwarfare/TWGameManagerTest.java new file mode 100644 index 00000000000..6513334ffa0 --- /dev/null +++ b/megamek/unittests/megamek/server/totalwarfare/TWGameManagerTest.java @@ -0,0 +1,92 @@ +package megamek.server.totalwarfare; + +import megamek.common.*; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Vector; + +import static org.junit.jupiter.api.Assertions.*; + +class TWGameManagerTest { + private Player player; + private TWGameManager gameManager; + private Game game; + + @BeforeAll + static void before() { + EquipmentType.initializeTypes(); + } + + @BeforeEach + void setUp() { + player = new Player(0, "Test"); + gameManager = new TWGameManager(); + game = gameManager.getGame(); + game.addPlayer(0, player); + } + + @Test + void testAddControlWithAdvAtmosphericMergesIntoOneRollAero() { + AeroSpaceFighter aero = new AeroSpaceFighter(); + game.addEntity(aero); + Vector rolls = new Vector<>(); + StringBuilder reasons = new StringBuilder(); + + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "critical hit")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "avionics hit")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "threshold")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "highest damage threshold exceeded")); + gameManager.addControlWithAdvAtmospheric(aero, rolls, reasons); + assertEquals(1, rolls.size()); + } + + @Test + void testAddControlWithAdvAtmosphericIncludesAllReasonsAero() { + AeroSpaceFighter aero = new AeroSpaceFighter(); + game.addEntity(aero); + Vector rolls = new Vector<>(); + StringBuilder reasons = new StringBuilder(); + + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "critical hit")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "avionics hit")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "threshold")); + game.addControlRoll(new PilotingRollData(aero.getId(), 0, "highest damage threshold exceeded")); + gameManager.addControlWithAdvAtmospheric(aero, rolls, reasons); + assertTrue(reasons.toString().contains("critical hit")); + assertTrue(reasons.toString().contains("avionics hit")); + assertTrue(reasons.toString().contains("threshold")); + assertTrue(reasons.toString().contains("highest damage threshold exceeded")); + } + + @Test + void testAddControlWithAdvAtmosphericMergesIntoOneRollLAM() { + LandAirMech mech = new LandAirMech(LandAirMech.GYRO_STANDARD, LandAirMech.COCKPIT_STANDARD, LandAirMech.LAM_STANDARD); + game.addEntity(mech); + Vector rolls = new Vector<>(); + StringBuilder reasons = new StringBuilder(); + + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "avionics hit")); + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "threshold")); + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "highest damage threshold exceeded")); + gameManager.addControlWithAdvAtmospheric(mech, rolls, reasons); + assertEquals(1, rolls.size()); + } + + @Test + void testAddControlWithAdvAtmosphericIncludesAllReasonsLAM() { + LandAirMech mech = new LandAirMech(LandAirMech.GYRO_STANDARD, LandAirMech.COCKPIT_STANDARD, LandAirMech.LAM_STANDARD); + game.addEntity(mech); + Vector rolls = new Vector<>(); + StringBuilder reasons = new StringBuilder(); + + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "avionics hit")); + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "threshold")); + game.addControlRoll(new PilotingRollData(mech.getId(), 0, "highest damage threshold exceeded")); + gameManager.addControlWithAdvAtmospheric(mech, rolls, reasons); + assertTrue(reasons.toString().contains("avionics hit")); + assertTrue(reasons.toString().contains("threshold")); + assertTrue(reasons.toString().contains("highest damage threshold exceeded")); + } +} \ No newline at end of file