diff --git a/megamek/src/megamek/client/bot/princess/Princess.java b/megamek/src/megamek/client/bot/princess/Princess.java index d2199343c5d..9c4e4a74ce9 100644 --- a/megamek/src/megamek/client/bot/princess/Princess.java +++ b/megamek/src/megamek/client/bot/princess/Princess.java @@ -29,6 +29,7 @@ import megamek.common.actions.*; import megamek.common.annotations.Nullable; import megamek.common.containers.PlayerIDandList; +import megamek.common.enums.AimingMode; import megamek.common.equipment.WeaponMounted; import megamek.common.event.GameCFREvent; import megamek.common.event.GamePlayerChatEvent; @@ -59,6 +60,57 @@ public class Princess extends BotClient { private static final int MAX_OVERHEAT_AMS = 14; + /** + * Highest target number to consider when not aiming at the head on an immobile Mech + */ + private static final int SHUTDOWN_MAX_TARGETNUMBER = 12; + + /** + * Default maximum location armor for checking called shots + */ + private static final int CALLED_SHOT_DEFAULT_MAXARMOR = 10; + + /** + * Combined armor and structure value where a location is at risk of destruction + */ + private static final int LOCATION_DESTRUCTION_THREAT = 5; + + /** + * Difference in to-hit number between a general shot at an immobile Mech, and aiming for the head + */ + private static final int IMMOBILE_HEADSHOT_MODIFIER = 7; + + /** + * To-hit modifier for aimed shots against active targets + */ + private static final int AIMED_SHOT_MODIFIER = 3; + + /** + * To-hit modifier for called shots + */ + private static final int CALLED_SHOT_MODIFIER = 3; + + /** + * Minimum damage to be considered as a 'big gun' for prioritizing aimed shot locations + */ + private static final int BIG_GUN_MIN_DAMAGE = 10; + + /** + * Range to check damage when determining if a weapon is a 'big gun' + */ + private static final int BIG_GUN_TYPICAL_RANGE = 5; + + /** + * Minimum walking speed to consider for calling a shot low + */ + private static final int CALLED_SHOT_MIN_MOVE = 6; + + /** + * Minimum jump distance to consider for calling a shot low + */ + private static final int CALLED_SHOT_MIN_JUMP = 5; + + private final IHonorUtil honorUtil = new HonorUtil(); private boolean initialized = false; @@ -95,6 +147,19 @@ public class Princess extends BotClient { // Track entities that fired an AMS manually this round private List manualAMSIds; + // Master switch to enable/disable use of enhanced targeting system (aimed/called shots) + private boolean enableEnhancedTargeting; + + // Limits types of units Princess will target and attack with enhanced targeting + private List enhancedTargetingTargetTypes; + private List enhancedTargetingAttackerTypes; + + // Controls whether Princess will use called shots on immobile targets + private boolean useCalledShotsOnImmobileTarget; + + // Controls whether Princess will use enhanced targeting on targets that have partial cover + private boolean allowCoverEnhancedTargeting; + /** * Returns a new Princess Bot with the given behavior and name, configured for the given * host and port. The new Princess Bot outputs its settings to its own logger. @@ -119,6 +184,9 @@ public Princess(final String name, final String host, final int port) { fireControlState = new FireControlState(); pathRankerState = new PathRankerState(); + // Set up enhanced targeting + resetEnhancedTargeting(true); + // Start-up precog now, so that it can instantiate its game instance, // and it will stay up-to date. precognition = new Precognition(this); @@ -625,6 +693,64 @@ protected void calculateFiringTurn() { logger.debug(shooter.getDisplayName() + " - Detailed Best Firing Plan: " + plan.getDebugDescription(true)); + // Consider making an aimed shot if the target is shut down or the attacker has + // a targeting computer. Alternatively, consider using the called shots optional + // rule to adjust the hit table to something more favorable. + + boolean isCalledShot = false; + int locationDestruction = Integer.MAX_VALUE; + int aimLocation = Mech.LOC_NONE; + int calledShotDirection = CalledShot.CALLED_NONE; + + WeaponFireInfo primaryFire = plan.get(0); + int targetID; + if (primaryFire != null) { + targetID = primaryFire.getTarget().getId(); + } else { + targetID = Entity.NONE; + } + + if (targetID > Entity.NONE && + primaryFire.getTarget() != null && + plan.stream().allMatch(curFire -> primaryFire.getTarget().getId() == targetID) && + checkForEnhancedTargeting(shooter, + (Entity) primaryFire.getTarget(), + primaryFire.getToHit().getCover())) { + + Entity aimTarget = (Mech) primaryFire.getTarget(); + if (game.getOptions().booleanOption(OptionsConstants.ADVCOMBAT_TACOPS_CALLED_SHOTS) && + (!aimTarget.isImmobile() || useCalledShotsOnImmobileTarget)) { + isCalledShot = true; + } + + // Check for an aimed shot + if (aimTarget.isImmobile() || shooter.hasTargComp()) { + boolean rearShot = primaryFire.getToHit().getSideTable() == ToHitData.SIDE_REAR; + + // Get the Mech location to aim at. Infantry and BA will go for the head + // if the odds are good. + aimLocation = getAimedShotLocation(primaryFire.getTarget(), + plan, rearShot, shooter.isInfantry()); + + // When aiming at a location, don't bother checking for called shots + if (aimLocation != Mech.LOC_NONE) { + isCalledShot = false; + // TODO: this should be adjusted to better handle multiple target types + locationDestruction = aimTarget.getArmor(aimLocation, rearShot) + aimTarget.getInternal(aimLocation); + } + + } + + if (isCalledShot) { + + calledShotDirection = getCalledShotDirection(primaryFire.getTarget(), + primaryFire.getToHit().getSideTable(), + plan); + + } + + } + // Add expected damage from the chosen FiringPlan to the // damageMap for the target enemy. // while we're looping through all the shots anyway, send any firing mode changes @@ -641,6 +767,13 @@ protected void calculateFiringTurn() { } } + // Set attacks as aimed or called, as required + if (aimLocation != Mech.LOC_NONE || calledShotDirection != CalledShot.CALLED_NONE) { + setAttackAsAimedOrCalled(shot, + aimLocation, + calledShotDirection, + locationDestruction); + } if (shot.getUpdatedFiringMode() != null) { super.sendModeChange(shooter.getId(), shooter.getEquipmentNum(shot.getWeapon()), shot.getUpdatedFiringMode()); } @@ -942,6 +1075,693 @@ protected Vector calculateMinefieldDeployment() { } } + + + // Enhanced targeting controls + + public boolean getEnhancedTargetingControl () { + return enableEnhancedTargeting; + } + + public void setEnableEnhancedTargeting (boolean newSetting) { + enableEnhancedTargeting = newSetting; + } + + /** + * Sets all enhanced targeting controls to default values and optionally enables its use + * @param enable true to immediately enable enhanced targeting features after reset + */ + public void resetEnhancedTargeting (boolean enable) { + + // Toggle enhanced targeting + enableEnhancedTargeting = enable; + + // Set default enhanced targeting target and attacker types + enhancedTargetingTargetTypes = new ArrayList<>(Arrays.asList( + UnitType.MEK + )); + enhancedTargetingAttackerTypes = new ArrayList<>(Arrays.asList( + UnitType.MEK, + UnitType.TANK, + UnitType.BATTLE_ARMOR, + UnitType.INFANTRY, + UnitType.PROTOMEK, + UnitType.VTOL, + UnitType.GUN_EMPLACEMENT + )); + + // Set default as not using called shots against immobile targets + useCalledShotsOnImmobileTarget = false; + + // Set default as not allowing enhanced targeting if the target has partial cover. + // This prevents all sorts of issues, such as aiming for locations that are covered. + allowCoverEnhancedTargeting = false; + + } + + /** + * Swap out current set of valid enhanced targeting target types for a new set. Automatically + * removes certain types that will never apply, such as infantry. + * @param newTargetTypes List of {@link UnitType} constants, may be empty or null to clear + */ + public void setEnhancedTargetingTargetTypes (List newTargetTypes) { + enhancedTargetingTargetTypes = Objects.requireNonNullElseGet(newTargetTypes, ArrayList::new); + if (enhancedTargetingTargetTypes.contains(UnitType.INFANTRY)) { + enhancedTargetingTargetTypes.remove(UnitType.INFANTRY); + } + if (enhancedTargetingTargetTypes.contains(UnitType.BATTLE_ARMOR)) { + enhancedTargetingTargetTypes.remove(UnitType.BATTLE_ARMOR); + } + } + + /** + * Swap out current set of valid enhanced targeting attacker types for a new set + * @param newAttackerTypes List of {@link UnitType} constants, may be empty or null to clear + */ + public void setEnhancedTargetingAttackerTypes (List newAttackerTypes) { + enhancedTargetingAttackerTypes = Objects.requireNonNullElseGet(newAttackerTypes, ArrayList::new); + } + + /** + * Returns a copy of the list of valid enhanced targeting target types + * @return list of {@link UnitType} constants, or empty list + */ + public List seeEnhancedTargetingTargetTypes () { + return new ArrayList<>(enhancedTargetingTargetTypes); + } + + /** + * Returns a copy of the list of valid enhanced targeting attacker types + * @return list of {@link UnitType} constants, or empty list + */ + public List seeEnhancedTargetingAttackerTypes () { + return new ArrayList<>(enhancedTargetingAttackerTypes); + } + + /** + * Checks if the supplied unit type is considered a valid target for enhanced targeting + * @param testType {@link UnitType} constant + * @return true, if unit is a valid target for enhanced targeting + */ + public boolean isValidEnhancedTargetingTarget (int testType) { + if (enhancedTargetingTargetTypes != null) { + return enhancedTargetingTargetTypes.contains(testType); + } else { + return false; + } + } + + /** + * Checks if the supplied unit type is considered a valid attacker for enhanced targeting + * @param testType {@link UnitType} constant + * @return true, if unit is a valid attacker for enhanced targeting + */ + public boolean isValidEnhancedTargetingAttacker (int testType) { + if (enhancedTargetingAttackerTypes != null) { + return enhancedTargetingAttackerTypes.contains(testType); + } else { + return false; + } + } + + public boolean getAllowCalledShotsOnImmobile () { + return useCalledShotsOnImmobileTarget; + } + + public void setAllowCalledShotsOnImmobile (boolean newSetting) { + useCalledShotsOnImmobileTarget = newSetting; + } + + public boolean getPartialCoverEnhancedTargeting () { + return allowCoverEnhancedTargeting; + } + + /** + * Controls whether enhanced targeting will be used against targets with partial cover from + * the shooter. Use with caution as this can result in situations like aiming for a location + * which is protected by intervening cover. + * @param newSetting true, to allow aimed/called shots against targets with partial cover + */ + public void setPartialCoverEnhancedTargeting (boolean newSetting) { + allowCoverEnhancedTargeting = newSetting; + } + + /** + * Determine if a shooter should consider using enhanced targeting - aimed or called shots - + * against a given target. This includes some basic filtering for unit types and equipment such + * targeting computers. + * @param shooter Entity doing the shooting + * @param target Entity being shot at + * @param cover {@link LosEffects} constant for partial cover, derived from {@code ToHitData.getCover()} + * @return true, if aimed or called shots should be checked + */ + protected boolean checkForEnhancedTargeting (Entity shooter, + Entity target, + int cover) { + + if (!enableEnhancedTargeting) { + return false; + } + + // Partial cover adds all sorts of complications, don't bother unless enabled + if (cover != LosEffects.COVER_NONE && !allowCoverEnhancedTargeting) { + return false; + } + + // Basic unit type filtering for shooter. Ejected crews are considered infantry, so need + // to be specifically checked. + if (!isValidEnhancedTargetingAttacker(shooter.getUnitType()) || + shooter instanceof EjectedCrew) { + return false; + } + + if (!isValidEnhancedTargetingTarget(target.getUnitType())) { + return false; + } + + boolean useAimedShot = false; + boolean useCalledShot = false; + + // Only certain unit types can be the target of aimed shots + List validAimTypes = new ArrayList<>(Arrays.asList( + UnitType.MEK, + UnitType.TANK, + UnitType.VTOL, + UnitType.CONV_FIGHTER, + UnitType.AEROSPACEFIGHTER + )); + + if (validAimTypes.contains(target.getUnitType())) { + + // Aimed shots are only possible if the target is immobile or the shooter has a + // targeting computer + if (target.isImmobile() || shooter.hasTargComp()) { + useAimedShot = true; + } + } + + if (game.getOptions().booleanOption(OptionsConstants.ADVCOMBAT_TACOPS_CALLED_SHOTS)) { + // Called shots against immobile targets can be a little too effective, so only use + // when enabled + if (!target.isImmobile() || useCalledShotsOnImmobileTarget) { + useCalledShot = true; + } + } + + return useAimedShot || useCalledShot; + } + + /** + * Determine which location to aim for on a general target. Returned location constant is + * relative to the provided target type. + * Currently only supports aimed shots against Mechs. + * @param target Entity being shot at + * @param planOfAttack Proposed attacks against {@code target} + * @param rearAttack true if attacking from rear arc + * @param includeHead true if the head is a valid location, ignored for non-Mech targets + * @return location constant to aim for, with the {@code LOC_NONE} constant + * indicating no suitable location + */ + protected int getAimedShotLocation (Targetable target, + FiringPlan planOfAttack, + boolean rearAttack, + boolean includeHead) { + + int aimLocation = Entity.LOC_NONE; + if (planOfAttack == null || target == null) { + return aimLocation; + } + + // Only check attacks if the target has locations that can be aimed for, which can be + // aimed, and are against the designated target + List validAimTypes = new ArrayList<>(Arrays.asList( + UnitType.MEK, + UnitType.TANK, + UnitType.VTOL, + UnitType.CONV_FIGHTER, + UnitType.AEROSPACEFIGHTER + )); + + if (!validAimTypes.contains(((Entity) target).getUnitType())) { + return aimLocation; + } + + List workingShots = planOfAttack. + stream(). + filter(curShot -> curShot.getTarget().getId() == target.getId()). + filter(curShot -> + Compute.allowAimedShotWith(curShot.getWeapon(), + target.isImmobile() ? AimingMode.IMMOBILE : AimingMode.TARGETING_COMPUTER)). + collect(Collectors.toList()); + + if (workingShots.isEmpty()) { + return aimLocation; + } + + // Each type of unit requires its own checking process due to unique locations. + // TODO: placeholders are used for non-Mech targets. Create appropriate methods for each. + switch (((Entity) target).getUnitType()) { + case UnitType.MEK: + aimLocation = calculateAimedShotLocation( + (Mech) target, + workingShots, + rearAttack, + includeHead + ); + break; + case UnitType.TANK: + case UnitType.VTOL: + case UnitType.NAVAL: + aimLocation = Entity.LOC_NONE; + break; + case UnitType.CONV_FIGHTER: + case UnitType.AEROSPACEFIGHTER: + aimLocation = Entity.LOC_NONE; + break; + default: + break; + } + + return aimLocation; + } + + /** + * Determine which direction to make a called shot - left, right, high, or low. Some target + * types only support calling shots left or right. + * Currently only supports called shots against Mechs. + * @param target Entity being shot at + * @param attackSide {@link ToHitData} SIDE_ constant, indicating attack direction relative + * to target + * @param planOfAttack Proposed attacks against {@code target} + * @return {@link CalledShot} constant indicating which direction to call, may + * return {@code CalledShot.CALLED_NONE}. + */ + protected int getCalledShotDirection (Targetable target, + int attackSide, + FiringPlan planOfAttack) { + int calledShotDirection = CalledShot.CALLED_NONE; + if (planOfAttack == null || target == null) { + return calledShotDirection; + } + + WeaponFireInfo primaryFire = planOfAttack.get(0); + if (primaryFire == null) { + return calledShotDirection; + } + + // Limit the weapons fire to those against the designated target and which have a + // reasonable to-hit number + int maximumToHit = calcEnhancedTargetingMaxTN(false); + List workingShots = planOfAttack. + stream(). + filter(curShot -> curShot.getTarget().getId() == target.getId()). + filter(curShot -> curShot.getToHit().getValue() + CALLED_SHOT_MODIFIER <= maximumToHit). + collect(Collectors.toList()); + + if (workingShots.isEmpty()) { + return calledShotDirection; + } + + if (target instanceof Mech) { + calledShotDirection = calculateCalledShotDirection((Mech) target, attackSide, workingShots); + } else { + // TODO: placeholder for non-Mech targets. Create appropriate methods for each. + calledShotDirection = CalledShot.CALLED_NONE; + } + + return calledShotDirection; + } + + /** + * Determine which location to aim for on a Mech. Prioritizes torsos and legs, and ignores + * destroyed locations. Prefers right to left, given that most non-symmetrical Mechs are + * 'right-handed'. + * @param target Mech being shot at + * @param aimedShots Proposed attacks against {@code target} + * @param rearAttack true if attacking from the rear arc + * @param includeHead true to include the head as a valid location + * @return {@link Mech} constant for location to shoot, or {@code Mech.LOC_NONE} + * for none + */ + protected int calculateAimedShotLocation (Mech target, + List aimedShots, + boolean rearAttack, + boolean includeHead) { + int aimLocation = Mech.LOC_NONE; + + if (aimedShots == null || target == null) { + return aimLocation; + } + + WeaponFireInfo primaryFire = aimedShots.get(0); + int lowestArmor = Integer.MAX_VALUE; + List rankedLocations = new ArrayList<>(); + + // Aiming for the head can only be done against an immobile Mech, and takes a penalty. + // Don't aim for the head for anti-Mech attacks except after swarming. + if (includeHead && + target.isImmobile() && + !primaryFire.getWeapon().getShortName().equalsIgnoreCase(Infantry.LEG_ATTACK) && + !primaryFire.getWeapon().getShortName().equalsIgnoreCase(Infantry.SWARM_MEK) && + !primaryFire.getWeapon().getShortName().equalsIgnoreCase(Infantry.STOP_SWARM)) { + aimLocation = Mech.LOC_HEAD; + int headshotMaxTN = calcEnhancedTargetingMaxTN(false); + if (aimedShots.stream().anyMatch(curFire -> curFire.getToHit().getValue() + + IMMOBILE_HEADSHOT_MODIFIER > headshotMaxTN)) { + aimLocation = Mech.LOC_NONE; + } else { + return aimLocation; + } + } + + // Limit leg attack aimed shots to the legs + if (!primaryFire.getWeapon().getShortName().equalsIgnoreCase(Infantry.LEG_ATTACK)) { + + // Consider arm locations if they have a 'big' weapon + for (WeaponMounted curWeapon : target.getWeaponList(). + stream(). + filter(w -> w.isOperable() && isBigGun(w)). + collect(Collectors.toSet())) { + + if (!rankedLocations.contains(Mech.LOC_RARM) && + curWeapon.getLocation() == Mech.LOC_RARM && + target.getInternal(Mech.LOC_RARM) > 0) { + rankedLocations.add(Mech.LOC_RARM); + } else if (!rankedLocations.contains(Mech.LOC_LARM) && + curWeapon.getLocation() == Mech.LOC_LARM && + target.getInternal(Mech.LOC_LARM) > 0) { + rankedLocations.add(Mech.LOC_LARM); + } + if (rankedLocations.contains(Mech.LOC_RARM) && rankedLocations.contains(Mech.LOC_LARM)) { + break; + } + + } + + // Most Mech designs will have their main weapon in either the right torso or right arm, + // so going after the right torso first solves both conditions. Putting the right torso + // first ensures the left torso and other locations will only supersede it if they have + // taken more damage and make for a better target. + if (target.getInternal(Mech.LOC_RT) > 0) { + rankedLocations.add(Mech.LOC_RT); + } else if (target.getInternal(Mech.LOC_LT) > 0) { + rankedLocations.add(Mech.LOC_LT); + } + + if (!rankedLocations.contains(Mech.LOC_LT)) { + if (target.getInternal(Mech.LOC_LT) > 0) { + rankedLocations.add((Mech.LOC_LT)); + } + } + + rankedLocations.add(Mech.LOC_CT); + } + + // Favor right leg over left due to damage transfer to right torso, except if right leg is + // completely gone + if (target.getInternal(Mech.LOC_RLEG) > 0) { + rankedLocations.add(Mech.LOC_RLEG); + } else if (target.getInternal(Mech.LOC_LLEG) > 0) { + rankedLocations.add(Mech.LOC_LLEG); + } + + if (!rankedLocations.contains(Mech.LOC_LLEG)) { + if (target.getInternal(Mech.LOC_LLEG) > 0) { + rankedLocations.add(Mech.LOC_LLEG); + } + } + + // Select the most vulnerable location + int locationDestruction = 0; + for (int curLocation : rankedLocations) { + int locationArmor = Math.max(target.hasRearArmor(curLocation) ? + target.getArmor(curLocation, rearAttack) : + target.getArmor(curLocation), 0); + + if (target.getInternal(curLocation) > 0 && + (lowestArmor > locationArmor || + locationDestruction > locationArmor + target.getInternal(curLocation))) { + + aimLocation = curLocation; + lowestArmor = locationArmor; + locationDestruction = lowestArmor + target.getInternal(aimLocation); + + } + + // Doesn't get any better than a torso with no armor + if (lowestArmor == 0 && + (aimLocation == Mech.LOC_RT || + aimLocation == Mech.LOC_LT || + aimLocation == Mech.LOC_CT)) { + break; + } + } + // Evaluate whether all the weapons at the chosen location will be effective + if (aimLocation != Mech.LOC_NONE && + (!target.isImmobile() || aimLocation == Mech.LOC_HEAD)) { + + int offset = 0; + if (locationDestruction <= LOCATION_DESTRUCTION_THREAT) { + offset = 1; + } + + int penetratorCount = 0; + double totalDamage = 0; + int maximumToHit = calcEnhancedTargetingMaxTN(target.isImmobile() && aimLocation != Mech.LOC_HEAD); + for (WeaponFireInfo curFire : aimedShots) { + if (curFire.getToHit().getValue() + (aimLocation == Mech.LOC_HEAD ? + IMMOBILE_HEADSHOT_MODIFIER : AIMED_SHOT_MODIFIER) <= (maximumToHit + offset)) { + + totalDamage += curFire.getMaxDamage(); + if (curFire.getMaxDamage() >= lowestArmor) { + penetratorCount++; + } + + } + } + + // If none of the weapons have a low enough to-hit number, or if none of the weapons + // can penetrate the armor individually or cumulatively, don't bother aiming + if (totalDamage == 0 || + (penetratorCount == 0 && 0.4 * totalDamage < lowestArmor)) { + aimLocation = Mech.LOC_NONE; + } + + } + + return aimLocation; + } + + /** + * Determine which direction to make a called shot against a Mech - left, right, high, or low. + * Shots into a side arc will be called to become rear shots. Shots to the front or rear will + * call high or low based on how many locations have minimal armor. + * @param target Mech being shot at + * @param attackSide {@link ToHitData} SIDE_ constant, indicating attack direction relative + * to target + * @param calledShots Proposed attacks against {@code target} parameter + * @return {@link CalledShot} constant indicating which direction to call, may + * return {@code CalledShot.CALLED_NONE}. + */ + protected int calculateCalledShotDirection (Mech target, + int attackSide, + List calledShots) { + int calledShotDirection = CalledShot.CALLED_NONE; + + if (calledShots == null || + calledShots.isEmpty() || + calledShots.get(0) == null) { + return calledShotDirection; + } + + WeaponFireInfo primaryFire = calledShots.get(0); + + // If the target is being shot in a side arc, set the call direction to hit the rear arc + if (attackSide == ToHitData.SIDE_LEFT || attackSide == ToHitData.SIDE_REARLEFT) { + calledShotDirection = CalledShot.CALLED_RIGHT; + } else if (attackSide == ToHitData.SIDE_RIGHT || attackSide == ToHitData.SIDE_REARRIGHT) { + calledShotDirection = CalledShot.CALLED_LEFT; + } + + if (attackSide == ToHitData.SIDE_FRONT || attackSide == ToHitData.SIDE_REAR) { + + List upperLocations = new ArrayList<>(Arrays.asList(Mech.LOC_RT, + Mech.LOC_LT, + Mech.LOC_CT)); + + // Only consider the arms if they have 'big' weapons + for (WeaponMounted curWeapon : target.getWeaponList(). + stream(). + filter(w -> w.isOperable() && isBigGun(w)). + collect(Collectors.toSet())) { + + if (!upperLocations.contains(Mech.LOC_RARM) && + curWeapon.getLocation() == Mech.LOC_RARM && + target.getInternal(Mech.LOC_RARM) > 0) { + upperLocations.add(Mech.LOC_RARM); + } else if (!upperLocations.contains(Mech.LOC_LARM) && + curWeapon.getLocation() == Mech.LOC_LARM && + target.getInternal(Mech.LOC_LARM) > 0) { + upperLocations.add(Mech.LOC_LARM); + } + if (upperLocations.contains(Mech.LOC_RARM) && upperLocations.contains(Mech.LOC_LARM)) { + break; + } + + } + + // Establish a maximum armor value for calling shots high/low. If most + // of the locations have more armor than this, it's not a good option. + // Infantry and battle armor weapons rely on many small hits, so use + // a default value. + int armorThreshold; + Entity shooter = primaryFire.getShooter(); + if (!shooter.isInfantry()) { + OptionalDouble averageDamage = calledShots.stream().mapToDouble(WeaponFireInfo::getMaxDamage).average(); + if (averageDamage.isPresent()) { + armorThreshold = (int) Math.floor(averageDamage.getAsDouble()); + } else { + armorThreshold = CALLED_SHOT_DEFAULT_MAXARMOR; + } + } else { + armorThreshold = CALLED_SHOT_DEFAULT_MAXARMOR; + } + + double upperTargets = upperLocations. + stream(). + mapToInt(loc -> loc). + filter(loc -> target.getArmor(loc, attackSide == ToHitData.SIDE_REAR) <= armorThreshold). + count(); + + // Only consider shooting low if both legs are intact + double lowerTargets = 0; + if (target.getInternal(Mech.LOC_RLEG) > 0 && target.getInternal(Mech.LOC_LLEG) > 0) { + if (target.getArmor(Mech.LOC_RLEG) <= armorThreshold) { + lowerTargets++; + } + if (target.getArmor(Mech.LOC_LLEG) <= armorThreshold) { + lowerTargets++; + } + } + + // If the head armor is weak or there are proportionally more upper targets, call high. + // If the leg armor is weak and this is a fast and/or jumping Mech, call low. + if (target.getArmor(Mech.LOC_HEAD) + target.getInternal(Mech.LOC_HEAD) <= LOCATION_DESTRUCTION_THREAT || + (upperTargets / upperLocations.size() > lowerTargets / 2.0)) { + calledShotDirection = CalledShot.CALLED_HIGH; + } else if (lowerTargets >= 1 || + target.getWalkMP() >= CALLED_SHOT_MIN_MOVE || + target.getJumpMP() >= CALLED_SHOT_MIN_JUMP) { + calledShotDirection = CalledShot.CALLED_LOW; + } + + } + + return calledShotDirection; + } + + + /** + * Checks if a weapon is considered a 'big gun' worth taking a shot at + * @param testWeapon weapon to check + * @return true if weapon damage exceeds {@code BIG_GUN_MIN_DAMAGE} at a + * typical range value + */ + private boolean isBigGun(WeaponMounted testWeapon) { + return testWeapon.getType().getDamage(BIG_GUN_TYPICAL_RANGE) >= BIG_GUN_MIN_DAMAGE; + } + + /** + * Figure out the highest practical to-hit number for enhanced aiming (aimed/called shots), + * using behavior settings + * @param isAimedImmobile true if making aimed shot at immobile target + * @return maximum to-hit number for a weapons attack with enhanced aiming + */ + private int calcEnhancedTargetingMaxTN (boolean isAimedImmobile) { + if (isAimedImmobile) { + return SHUTDOWN_MAX_TARGETNUMBER; + } else { + return Math.max(10 - getBehaviorSettings().getSelfPreservationIndex(), 2); + } + } + + + /** + * If a shot meets criteria, set it as aimed or called. {@code aimLocation} and {@code + * calledShotDirection} are not mutually exclusive - if both are provided, weapons which + * cannot make an aimed shot will make a called shot instead + * @param shot Single-weapon attack action + * @param aimLocation {@link Mech} LOC_ constant with aiming location + * @param destructionThreshold how much damage to completely destroy the location + */ + protected void setAttackAsAimedOrCalled (WeaponFireInfo shot, + int aimLocation, + int calledShotDirection, + int destructionThreshold) { + Entity shooter = shot.getShooter(); + + int offset = 0; + + // If the target is a Mech and the attack is not artillery or non-damaging anti-Mech + if (shot.getTarget().getTargetType() == UnitType.MEK && + !shot.getWeapon().getType().hasFlag(WeaponType.F_ARTILLERY) && + !shot.getWeapon().getShortName().equalsIgnoreCase(Infantry.SWARM_MEK) && + !shot.getWeapon().getShortName().equalsIgnoreCase(Infantry.STOP_SWARM)) { + + Mech target = (Mech) shot.getTarget(); + int maximumTN; + + // If set for aimed shots, and the weapon can make aimed shots + if ((aimLocation != Mech.LOC_NONE) && + Compute.allowAimedShotWith(shot.getWeapon(), target.isImmobile() ? AimingMode.IMMOBILE : AimingMode.TARGETING_COMPUTER)) { + + maximumTN = calcEnhancedTargetingMaxTN(target.isImmobile() && aimLocation != Mech.LOC_HEAD); + + // Increase the maximum target number for attacks that may destroy the location, + // as well as infantry weapons which may have multiple hits per shot + if ((!shooter.isInfantry() && shot.getMaxDamage() >= destructionThreshold) || + shot.getWeapon().getType().hasFlag(WeaponType.F_INFANTRY)) { + offset = 1; + } + + // If the target number is considered viable set attack as aimed + // at the provided location + if ((shot.getToHit().getValue() + (target.isImmobile() ? 0 : AIMED_SHOT_MODIFIER)) <= (maximumTN + offset)) { + shot.getAction().setAimingMode(target.isImmobile() ? AimingMode.IMMOBILE : AimingMode.TARGETING_COMPUTER); + shot.getAction().setAimedLocation(aimLocation); + } + + } else if (calledShotDirection != CalledShot.CALLED_NONE) { + + maximumTN = calcEnhancedTargetingMaxTN(false); + + // If the weapon uses the cluster table, increase the maximum target number + if (shot.getWeapon().getType().getDamage() == WeaponType.DAMAGE_BY_CLUSTERTABLE || + (shot.getAmmo() != null && + shot.getAmmo().getType().getMunitionType().contains(AmmoType.Munitions.M_CLUSTER)) || + shot.getWeapon().getType().hasFlag(WeaponType.F_INFANTRY)) { + offset = 2; + } + + // If the target number is considered viable, step through the options until + // it gets to the desired setting + if ((shot.getToHit().getValue() + CALLED_SHOT_MODIFIER) <= (maximumTN + offset)) { + // TODO: adjust send/receive method to transmit new called shot rather than stepping through + for (int i = 0; i < calledShotDirection; i++) { + sendCalledShotChange(shooter.getId(), shot.getWeaponAttackAction().getWeaponId()); + } + } + + } + + } + + } + + + + + /** * Gets an entity eligible to fire from a list contained in the fire control state. */ diff --git a/megamek/src/megamek/common/CalledShot.java b/megamek/src/megamek/common/CalledShot.java index 6d2b3ee4c77..eb32262a4bd 100644 --- a/megamek/src/megamek/common/CalledShot.java +++ b/megamek/src/megamek/common/CalledShot.java @@ -1,5 +1,5 @@ package megamek.common; - + import megamek.common.annotations.Nullable; import java.io.Serializable; @@ -9,7 +9,7 @@ */ public class CalledShot implements Serializable { private static final long serialVersionUID = 8746351140726246311L; - + // locations for called shots public static final int CALLED_NONE = 0; public static final int CALLED_HIGH = 1; @@ -17,29 +17,29 @@ public class CalledShot implements Serializable { public static final int CALLED_LEFT = 3; public static final int CALLED_RIGHT = 4; public static final int CALLED_NUM = 5; - + private int current; private static final String[] calledLocNames = {"", "HIGH", "LOW", "LEFT", "RIGHT"}; - + public CalledShot() { current = CALLED_NONE; } - + public String getDisplayableName() { if (current >= CALLED_NUM) { return "Unknown"; } return calledLocNames[current]; } - + public int switchCalledShot() { current = current + 1; if (current >= CALLED_NUM) { current = CALLED_NONE; } - return current; + return current; } - + public int getCall() { return current; } @@ -48,7 +48,7 @@ public int getCall() { if (current == CALLED_NONE) { return null; } - + if (!(target instanceof Entity)) { return "called shots on entities only"; } @@ -59,14 +59,14 @@ public int getCall() { // only meks can be high or low if (!(te instanceof Mech) && (current == CALLED_HIGH)) { - return "called shots (high) only on Meks"; + return "called shots (high) only on Meks"; } else if (!(te instanceof Mech) && (current == CALLED_LOW)) { - return "called shots (low) only on Meks"; + return "called shots (low) only on Meks"; } return null; } - + public void reset() { current = CALLED_NONE; }