diff --git a/megamek/src/megamek/common/alphaStrike/AlphaStrikeElement.java b/megamek/src/megamek/common/alphaStrike/AlphaStrikeElement.java index 1fa874843be..73184e9b1d4 100644 --- a/megamek/src/megamek/common/alphaStrike/AlphaStrikeElement.java +++ b/megamek/src/megamek/common/alphaStrike/AlphaStrikeElement.java @@ -166,9 +166,18 @@ public String getModel() { @Override public UnitRole getRole() { + UnitRole roleOverride = getRoleOverride(); + return roleOverride.hasRole() ? roleOverride : UnitRole.bestRoleFor(this); + } + + public UnitRole getRoleOverride() { return (role == null) ? UnitRole.UNDETERMINED : role; } + public boolean hasRoleOverride() { + return getRoleOverride() != UnitRole.UNDETERMINED; + } + /** * @return The AS element's display name, including duplicate markers such as "#2". */ @@ -376,7 +385,7 @@ public void setModel(String newModel) { * Sets the AS element's battlefield role (ROLE). */ public void setRole(UnitRole newRole) { - role = newRole; + role = (newRole == null) ? UnitRole.UNDETERMINED : newRole; } /** diff --git a/megamek/src/megamek/common/alphaStrike/conversion/ASConverter.java b/megamek/src/megamek/common/alphaStrike/conversion/ASConverter.java index 0d233efdcdc..dae283ea853 100644 --- a/megamek/src/megamek/common/alphaStrike/conversion/ASConverter.java +++ b/megamek/src/megamek/common/alphaStrike/conversion/ASConverter.java @@ -113,7 +113,7 @@ private static AlphaStrikeElement performConversion(Entity entity, boolean inclu element.setModel(entity.getModel()); element.setChassis(entity.getFullChassis()); element.setMulId(entity.getMulId()); - element.setRole(entity.getRole()); + element.setRole(entity.getRoleOverride()); element.setFluff(entity.getFluff()); element.setId(entity.getId()); @@ -129,7 +129,7 @@ private static AlphaStrikeElement performConversion(Entity entity, boolean inclu conversionReport.addLine("Chassis:", element.getChassis(), ""); conversionReport.addLine("Model:", element.getModel(), ""); conversionReport.addLine("MUL ID:", Integer.toString(element.getMulId()), ""); - conversionReport.addLine("Unit Role:", element.getRole().toString(), ""); + conversionReport.addLine("Unit Role:", element.getRoleOverride().toString(), ""); // Type element.setType(ASUnitType.getUnitType(entity)); diff --git a/megamek/src/megamek/common/loaders/BLKFile.java b/megamek/src/megamek/common/loaders/BLKFile.java index d745bb0cd12..8e66a0e5d0e 100644 --- a/megamek/src/megamek/common/loaders/BLKFile.java +++ b/megamek/src/megamek/common/loaders/BLKFile.java @@ -765,8 +765,8 @@ public static BuildingBlock getBlock(Entity t) throws EntitySavingException { String type = getType(t); blk.writeBlockData("type", type); - if (t.hasRole()) { - blk.writeBlockData("role", t.getRole().toString()); + if (t.hasRoleOverride()) { + blk.writeBlockData("role", t.getRoleOverride().toString()); } List quirkList = t.getQuirks() diff --git a/megamek/src/megamek/common/units/Entity.java b/megamek/src/megamek/common/units/Entity.java index 4d6c43667d8..24ce144cce0 100644 --- a/megamek/src/megamek/common/units/Entity.java +++ b/megamek/src/megamek/common/units/Entity.java @@ -17519,12 +17519,21 @@ protected final IGameOptions gameOptions() { } public void setUnitRole(UnitRole role) { - this.role = role; + this.role = (role == null) ? UnitRole.UNDETERMINED : role; + } + + public UnitRole getRoleOverride() { + return (role == null) ? UnitRole.UNDETERMINED : role; + } + + public boolean hasRoleOverride() { + return getRoleOverride() != UnitRole.UNDETERMINED; } @Override public UnitRole getRole() { - return (role == null) ? UnitRole.UNDETERMINED : role; + UnitRole roleOverride = getRoleOverride(); + return roleOverride.hasRole() ? roleOverride : UnitRole.bestRoleFor(this); } /** diff --git a/megamek/src/megamek/common/units/Mek.java b/megamek/src/megamek/common/units/Mek.java index 88eaa6394d4..8ed8d09d57d 100644 --- a/megamek/src/megamek/common/units/Mek.java +++ b/megamek/src/megamek/common/units/Mek.java @@ -5083,8 +5083,8 @@ public String getMtf() { sb.append(MtfFile.RULES_LEVEL).append( TechConstants.T_SIMPLE_LEVEL[techLevel]); sb.append(newLine); - if (hasRole()) { - sb.append(MtfFile.ROLE).append(getRole().toString()); + if (hasRoleOverride()) { + sb.append(MtfFile.ROLE).append(getRoleOverride().toString()); sb.append(newLine); } if (techFaction != null && techFaction != Faction.NONE) { diff --git a/megamek/src/megamek/common/units/UnitRole.java b/megamek/src/megamek/common/units/UnitRole.java index 3079312a0fd..352c8ea346d 100644 --- a/megamek/src/megamek/common/units/UnitRole.java +++ b/megamek/src/megamek/common/units/UnitRole.java @@ -77,6 +77,17 @@ public enum UnitRole { INTERCEPTOR(AERO), TRANSPORT(AERO); + private static final double NO_MATCH = -1_000_000; + private static final MMLogger LOGGER = MMLogger.create(UnitRole.class); + + private static final UnitRole[] GROUND_ROLES = { + MISSILE_BOAT, SNIPER, JUGGERNAUT, SCOUT, SKIRMISHER, STRIKER, AMBUSHER, BRAWLER + }; + + private static final UnitRole[] AERO_ROLES = { + ATTACK_FIGHTER, DOGFIGHTER, FAST_DOGFIGHTER, FIRE_SUPPORT, INTERCEPTOR + }; + enum Availability { GROUND(BTObject::isGround), AERO(BTObject::isAerospace), @@ -143,12 +154,56 @@ public static UnitRole parseRole(String role) { case "transport" -> TRANSPORT; case "none" -> NONE; default -> { - MMLogger.create(UnitRole.class).warn("Could not parse role: {}", role); + LOGGER.warn("Could not parse role: {}", role); yield UNDETERMINED; } }; } + /** + * Determines the best battlefield role for a unit using its converted Alpha Strike statistics. This does not read + * or replace the unit's persisted role. + * + * @param entity The unit to classify + * + * @return The best matching role, {@link #NONE} for support/non-combat units, or {@link #UNDETERMINED} when the + * unit cannot be converted + */ + public static UnitRole bestRoleFor(Entity entity) { + if (!ASConverter.canConvert(entity)) { + return UNDETERMINED; + } + AlphaStrikeElement element = ASConverter.convert(entity); + return (element == null) ? UNDETERMINED : bestRoleFor(element); + } + + /** + * Determines the best battlefield role for an Alpha Strike element. Ground units use the eight ground roles; + * units that return true for {@link BTObject#isAero()} use the aerospace role set. + * + * @param unit The Alpha Strike element to classify + * + * @return The best matching role, or {@link #NONE} when the unit is not classified by the role system + */ + public static UnitRole bestRoleFor(AlphaStrikeElement unit) { + if ((unit == null) || unit.isUnitGroup()) { + return UNDETERMINED; + } + + if (unit.isAero()) { + if (hasSignificantTransportCapacity(unit)) { + return TRANSPORT; + } else if (!isAeroCombatRoleCandidate(unit) || !hasMeaningfulAttack(unit)) { + return NONE; + } + return bestScoringRole(unit, AERO_ROLES, NONE); + } else if (isGroundCombatRoleCandidate(unit) && hasMeaningfulAttack(unit)) { + return bestScoringRole(unit, GROUND_ROLES, NONE); + } else { + return NONE; + } + } + /** * Applies the criteria from Alpha Strike Companion to determine whether a unit qualifies for a particular role. As * the canon unit roles do not themselves adhere strictly to the guidelines, there is some allowance for fuzziness @@ -211,8 +266,40 @@ public boolean qualifiesForRole(AlphaStrikeElement unit) { * @return Boolean value indicating whether the unit meets the qualifications for this role. */ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { - if (!isAvailableTo(unit)) { - return false; + if (this == NONE) { + return bestRoleFor(unit) == NONE; + } + + double score = roleScore(unit); + return (score > NO_MATCH) && (score >= tolerance); + } + + private static UnitRole bestScoringRole(AlphaStrikeElement unit, UnitRole[] candidateRoles, UnitRole fallback) { + UnitRole bestRole = fallback; + double bestScore = NO_MATCH; + for (UnitRole candidateRole : candidateRoles) { + double candidateScore = candidateRole.roleScore(unit); + if (candidateScore > bestScore) { + bestRole = candidateRole; + bestScore = candidateScore; + } + } + return bestRole; + } + + + public double roleScore(AlphaStrikeElement unit) { + if ((unit == null) || !isAvailableTo(unit)) { + return NO_MATCH; + } + + if (isGroundRole(this) && (!isGroundCombatRoleCandidate(unit) || !hasMeaningfulAttack(unit))) { + return NO_MATCH; + } + + if (isAeroRole(this) && (this != TRANSPORT) + && (!unit.isAero() || !isAeroCombatRoleCandidate(unit) || !hasMeaningfulAttack(unit))) { + return NO_MATCH; } double score = 0; @@ -256,7 +343,8 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { * number of smaller units with lower armor values that have an official role of * juggernaut. */ - score += Math.min(0, unit.getFullArmor() - (unit.getSize() + 4)); + score += Math.min(0, unit.getFullArmor() - (unit.getSize() + 3)); + score += Math.max(0, unit.getFullArmor() - 7) * 0.2; if (Math.max(unit.getStandardDamage().S().damage, unit.getStandardDamage().M().damage) * 2 >= unit.getFullArmor()) { score++; @@ -281,21 +369,48 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { } break; case MISSILE_BOAT: - /* Any artillery piece or can do damage by indirect fire at long range */ - return (unit.getStandardDamage().L().damage > 0 && unit.hasSUA(BattleForceSUA.IF)) - || unit.hasSUA(BattleForceSUA.ARTAIS) - || unit.hasSUA(BattleForceSUA.ARTAC) - || unit.hasSUA(BattleForceSUA.ARTBA) - || unit.hasSUA(BattleForceSUA.ARTCM5) - || unit.hasSUA(BattleForceSUA.ARTCM7) - || unit.hasSUA(BattleForceSUA.ARTCM9) - || unit.hasSUA(BattleForceSUA.ARTCM12) - || unit.hasSUA(BattleForceSUA.ARTT) - || unit.hasSUA(BattleForceSUA.ARTS) - || unit.hasSUA(BattleForceSUA.ARTLT) - || unit.hasSUA(BattleForceSUA.ARTTC) - || unit.hasSUA(BattleForceSUA.ARTSC) - || unit.hasSUA(BattleForceSUA.ARTLTC); + if (unit.hasSUA(BattleForceSUA.ENE)) { + score--; + } + double missileBoatIndirectFireValue = unit.getIF().damage; + if (missileBoatIndirectFireValue == 0 && unit.hasSUA(BattleForceSUA.IF)) { + missileBoatIndirectFireValue = 0.5; + } + score -= Math.max(0,unit.getStandardDamage().S().damage - 1 - missileBoatIndirectFireValue) * 0.5; + + /* Can do damage by indirect fire at long range */ + if (unit.getStandardDamage().L().damage > 0 && unit.hasSUA(BattleForceSUA.IF)) { + score += missileBoatIndirectFireValue; + score -= Math.max(0, (unit.getStandardDamage().L().damage - 1 - missileBoatIndirectFireValue)) * 0.5; + } else { + score--; + } + if (unit.hasSUA(BattleForceSUA.LRM)) { + score += unit.getLRM().L().damage * 0.5; + } + /* Any artillery piece */ + if (unit.hasSUA(BattleForceSUA.ARTCM7) + || unit.hasSUA(BattleForceSUA.ARTCM9) + || unit.hasSUA(BattleForceSUA.ARTCM12)) { + score += 5; + } + if (unit.hasSUA(BattleForceSUA.ARTCM5) + || unit.hasSUA(BattleForceSUA.ARTLT)) { + score += 4; + } + if (unit.hasSUA(BattleForceSUA.ARTAC) + || unit.hasSUA(BattleForceSUA.ARTAIS) + || unit.hasSUA(BattleForceSUA.ARTS) + || unit.hasSUA(BattleForceSUA.ARTLTC)) { + score += 3; + } + if (unit.hasSUA(BattleForceSUA.ARTBA) + ||unit.hasSUA(BattleForceSUA.ARTT) + || unit.hasSUA(BattleForceSUA.ARTTC) + || unit.hasSUA(BattleForceSUA.ARTSC)) { + score += 1.5; + } + break; case SCOUT: /* * Fast (jump, WiGE, or VTOL helpful but not required), lightly armored, @@ -320,10 +435,14 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { score++; } if (unit.getStandardDamage().S().damage > unit.getStandardDamage().M().damage) { - score++; - } else if (unit.getStandardDamage().S().damage > unit.getStandardDamage().L().damage) { score += 0.5; } + if (unit.getStandardDamage().S().damage > unit.getStandardDamage().L().damage) { + score += 0.5; + } + score -= (unit.getStandardDamage().S().damage * 0.1); + score -= (unit.getStandardDamage().M().damage * 0.25); + score -= (unit.getStandardDamage().L().damage * 0.5); break; case SKIRMISHER: /* Fast, medium-heavy armor with preference for medium range */ @@ -342,15 +461,37 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { break; case SNIPER: /* Can do damage at long range without LRMs */ - return unit.getStandardDamage().L().damage - unit.getLRM().L().damage > 0; + double sniperIndirectFireValue = unit.getIF().damage; + if (sniperIndirectFireValue == 0 && unit.hasSUA(BattleForceSUA.IF)) { + sniperIndirectFireValue = 0.5; + } + double damageWithoutIF = unit.getStandardDamage().L().damage - sniperIndirectFireValue; + score += (damageWithoutIF * 0.5); + score -= unit.getLRM().L().damage * 0.25; + score += (unit.getStandardDamage().L().damage - Math.max(unit.getStandardDamage().S().damage, + unit.getStandardDamage().M().damage)) * 0.25; + if (unit.hasSUA(BattleForceSUA.AMS) + || unit.hasSUA(BattleForceSUA.ECM)) { + score+=0.1; + } + if (unit.hasSUA(BattleForceSUA.C3M) + || unit.hasSUA(BattleForceSUA.C3S) + || unit.hasSUA(BattleForceSUA.C3I)) { + score+=0.1; + } + break; case STRIKER: /* Fast and light-medium armor, preference for short range */ score += Math.min(0, speed - 9) * 0.5; score -= Math.max(0, unit.getFullArmor() - 5); if (unit.getStandardDamage().S().damage > unit.getStandardDamage().M().damage) { score++; - } else if (unit.getStandardDamage().S().damage > unit.getStandardDamage().L().damage) { + } + if (unit.getStandardDamage().S().damage > unit.getStandardDamage().L().damage) { score += 0.5; + if (unit.getStandardDamage().S().damage == unit.getStandardDamage().M().damage) { + score += 0.5; + } } break; case ATTACK_FIGHTER: @@ -385,7 +526,7 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { case FIRE_SUPPORT: /* Not too slow and can do damage at long range */ if (unit.getStandardDamage().L().damage < 0.5) { - return false; + return NO_MATCH; } score += Math.min(0, speed - 5) + Math.min(0, 7 - speed); break; @@ -401,7 +542,7 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { break; case TRANSPORT: /* Has transport capacity */ - return unit.hasSUA(BattleForceSUA.CK) + if (unit.hasSUA(BattleForceSUA.CK) || unit.hasSUA(BattleForceSUA.IT) || unit.hasSUA(BattleForceSUA.AT) || unit.hasSUA(BattleForceSUA.PT) @@ -410,11 +551,66 @@ public boolean qualifiesForRole(AlphaStrikeElement unit, double tolerance) { || unit.hasSUA(BattleForceSUA.VTS) || unit.hasSUA(BattleForceSUA.MT) || unit.hasSUA(BattleForceSUA.CT) - || unit.hasSUA(BattleForceSUA.ST); + || unit.hasSUA(BattleForceSUA.ST)) { + score++; + } + break; default: break; } - return score >= tolerance; + return score; + } + + private static boolean isGroundRole(UnitRole role) { + return role.isAnyOf(AMBUSHER, BRAWLER, JUGGERNAUT, MISSILE_BOAT, SCOUT, SKIRMISHER, SNIPER, STRIKER); + } + + private static boolean isAeroRole(UnitRole role) { + return role.isAnyOf(ATTACK_FIGHTER, DOGFIGHTER, FAST_DOGFIGHTER, FIRE_SUPPORT, INTERCEPTOR, TRANSPORT); + } + + private static boolean isGroundCombatRoleCandidate(AlphaStrikeElement unit) { + return !unit.isAero() && (unit.isBattleMek() || unit.isProtoMek() || unit.isCombatVehicle() + || unit.isSupportVehicle() || unit.isBattleArmor()); + } + + private static boolean isAeroCombatRoleCandidate(AlphaStrikeElement unit) { + return unit.isFighter() || unit.hasMovementMode("a"); + } + + private static boolean hasMeaningfulAttack(AlphaStrikeElement unit) { + return unit.getStandardDamage().hasDamage() + || unit.getIF().hasDamage() + || unit.getLRM().hasDamage() + || unit.getIATM().hasDamage() + || unit.getREAR().hasDamage() + || unit.getAC().hasDamage() + || unit.getSRM().hasDamage() + || unit.getTOR().hasDamage() + || unit.getHT().hasDamage() + || unit.getFLK().hasDamage() + || unit.getCAP().hasDamage() + || unit.getSCAP().hasDamage() + || unit.getMSL().hasDamage() + || hasArtillery(unit) + || unit.hasSUA(BattleForceSUA.BOMB); + } + + private static boolean hasSignificantTransportCapacity(AlphaStrikeElement unit) { + return unit.hasSUA(BattleForceSUA.CK) + || unit.hasSUA(BattleForceSUA.IT) + || unit.hasSUA(BattleForceSUA.AT) + || unit.hasSUA(BattleForceSUA.PT) + || unit.hasSUA(BattleForceSUA.VTM) + || unit.hasSUA(BattleForceSUA.VTH) + || unit.hasSUA(BattleForceSUA.VTS) + || unit.hasSUA(BattleForceSUA.MT) + || unit.hasSUA(BattleForceSUA.ST) + || (unit.hasSUA(BattleForceSUA.CT) && (unit.getCT() >= 50)); + } + + private static boolean hasArtillery(AlphaStrikeElement unit) { + return Arrays.stream(BattleForceSUA.values()).anyMatch(sua -> sua.isArtillery() && unit.hasSUA(sua)); } @Override