diff --git a/megamek/data/forcegenerator/3058.xml b/megamek/data/forcegenerator/3058.xml index 0fc56d3e64b..8282135ba29 100644 --- a/megamek/data/forcegenerator/3058.xml +++ b/megamek/data/forcegenerator/3058.xml @@ -129,8 +129,8 @@ 25,25,53,90,100 - 53,53,80,46,100 - 47,47,20,54,0 + 53,53,80,96,100 + 47,47,20,4,0 0,0,20,20,20 100,100,80,80,80 CHH:0,CSR:0,CIH:0,CI:0,CSV:2,CFM:0,CCO:0,CGS:0,CSA:0,CDS:0,CW:6,LA:5,CNC:0,CSJ:0,CGB:0 diff --git a/megamek/data/forcegenerator/3060.xml b/megamek/data/forcegenerator/3060.xml index 7bf50c3fa8a..55f8b57c860 100644 --- a/megamek/data/forcegenerator/3060.xml +++ b/megamek/data/forcegenerator/3060.xml @@ -119,8 +119,8 @@ 25,25,55,90,100 - 55,55,80,10,100 - 45,45,20,90,0 + 55,55,80,90,100 + 45,45,20,10,0 0,0,20,20,20 100,100,80,80,80 CW:10,LA:2,CSV:4 diff --git a/megamek/src/megamek/client/ratgenerator/AbstractUnitRecord.java b/megamek/src/megamek/client/ratgenerator/AbstractUnitRecord.java index 27f4cdff6e6..ed5a9e7820e 100644 --- a/megamek/src/megamek/client/ratgenerator/AbstractUnitRecord.java +++ b/megamek/src/megamek/client/ratgenerator/AbstractUnitRecord.java @@ -40,23 +40,30 @@ public AbstractUnitRecord(String chassis) { } /** - * Adjusts availability rating for the first couple years after introduction. + * Adjusts availability rating for +/- dynamic. Also reduces availability by + * introduction year, with 1 year before heavily reduced for pre-production + * prototypes and first year slightly reduced for working out initial + * production. * - * @param ar The AvailabilityRecord for the chassis or model. - * @param rating The force equipment rating. + * @param avRating The AvailabilityRecord for the chassis or model. + * @param equipRating The force equipment rating. * @param ratingLevels The number of equipment rating levels used by the faction. - * @param year The game year + * @param year The game year * @return The adjusted availability rating. */ - public int calcAvailability(AvailabilityRating ar, int rating, int ratingLevels, int year) { - int retVal = ar.adjustForRating(rating, ratingLevels); + public int calcAvailability(AvailabilityRating avRating, int equipRating, int ratingLevels, int year) { + int retVal = avRating.adjustForRating(equipRating, ratingLevels); - if (introYear == year) { + // Pre-production prototypes are heavily reduced + if (year == introYear - 1) { retVal -= 2; } - if (introYear == year + 1) { + + // Initial production year is slightly reduced + if (year == introYear) { retVal -= 1; } + return Math.max(retVal, 0); } diff --git a/megamek/src/megamek/client/ratgenerator/AvailabilityRating.java b/megamek/src/megamek/client/ratgenerator/AvailabilityRating.java index b228c0774a7..400b2359c01 100644 --- a/megamek/src/megamek/client/ratgenerator/AvailabilityRating.java +++ b/megamek/src/megamek/client/ratgenerator/AvailabilityRating.java @@ -123,13 +123,26 @@ public int getAvailability() { return availability; } - public int adjustForRating(int rating, int numLevels) { - if (rating < 0 || ratingAdjustment == 0) { + /** + * Adjust availability rating for the dynamic +/- value, which is based on + * equipment quality rating. The (+) will reduce availability for commands + * with a lower rating, while the (-) will reduce availability for commands + * with a higher rating. + * @param equipRating zero-based index based on {@code numLevels} of rating to check + * @param numLevels number of equipment levels available, typically 5 (A/B/C/D/F) + * @return integer, may be negative + */ + public int adjustForRating(int equipRating, int numLevels) { + if (ratingAdjustment == 0 || equipRating < 0) { return availability; - } else if (ratingAdjustment < 0) { - return availability - rating; + } + + if (ratingAdjustment > 0) { + // (+) adjustment, reduce availability as equipment rating decreases + return availability - (numLevels - 1 - equipRating); } else { - return availability - (numLevels - rating); + // (-) adjustment, reduce availability as equipment rating increases + return availability - equipRating; } } diff --git a/megamek/src/megamek/client/ratgenerator/ChassisRecord.java b/megamek/src/megamek/client/ratgenerator/ChassisRecord.java index 883b3e53e46..a3909cac3fe 100644 --- a/megamek/src/megamek/client/ratgenerator/ChassisRecord.java +++ b/megamek/src/megamek/client/ratgenerator/ChassisRecord.java @@ -13,11 +13,9 @@ */ package megamek.client.ratgenerator; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; +import java.util.*; +import megamek.common.EntityMovementMode; import megamek.logging.MMLogger; /** @@ -55,7 +53,153 @@ public List getSortedModels() { } - public int totalModelWeight(int era, String fKey) { + /** + * Generate a list of models for this chassis based on certain criteria. + * Early prototypes may be available one year before official introduction. + * @param exactYear game year + * @param validWeightClasses restrict weight class to one or more classes + * @param movementModes movement mode types, may be null or empty + * @param networkMask specific C3 network equipment + * @return set of models which pass the filter requirements, may be empty + */ + public HashSet getFilteredModels(int exactYear, + Collection validWeightClasses, + Collection movementModes, + int networkMask) { + + HashSet filteredModels = new HashSet<>(); + + for (ModelRecord curModel : models) { + // Introduction date should be at most 1 year away for pre-production prototypes + if (curModel.introYear > exactYear + 1) { + continue; + } + + // Weight class check + if (validWeightClasses != null && !validWeightClasses.isEmpty() && !validWeightClasses.contains(curModel.getWeightClass())) { + continue; + } + + // Movement mode check + if (movementModes != null && !movementModes.isEmpty()) { + if (!movementModes.contains(curModel.getMovementMode())) { + continue; + } + } + + // C3 network equipment check + if ((networkMask & curModel.getNetworkMask()) != networkMask) { + continue; + } + + filteredModels.add(curModel); + } + + return filteredModels; + } + + /** + * Total the weights of all models for this chassis, including modifiers for + * +/- dynamic adjustment, intro year adjustment, interpolation, and role + * modifications. + * @param validModels models to add up + * @param currentEra year for current era + * @param exactYear current year in game + * @param nextEra start date of next era after the current one + * @param fRec faction data + * @param roles roles selected for generation, may be null or empty + * @param roleStrictness positive number, higher applies heavier role adjustments + * @param equipRating equipment rating to generate for + * @param numRatingLevels how many rating levels are present + * @return sum of calculated weights of all models of this chassis + */ + public double totalModelWeight(HashSet validModels, + int currentEra, + int exactYear, + int nextEra, + FactionRecord fRec, + Collection roles, + int roleStrictness, + int equipRating, + int numRatingLevels, + HashMap weightData) { + + RATGenerator ratGen = RATGenerator.getInstance(); + AvailabilityRating avRating, nextAvRating; + double retVal = 0; + double adjRating; + double nextRating; + Number roleRating; + + // Clear any pre-existing weighting data - this should only cover + // the current set of models + weightData.clear(); + + // For each model + for (ModelRecord curModel : validModels) { + + if (curModel.factionIsExcluded(fRec)) { + continue; + } + + // Get the availability rating for the provided faction and year, + // skip processing if not available + avRating = ratGen.findModelAvailabilityRecord(currentEra, curModel.getKey(), fRec); + if (avRating == null || avRating.getAvailability() <= 0) { + continue; + } + + // If required, interpolate availability between era start or intro date + // (whichever is later), and start of next era + if (exactYear > currentEra && currentEra != nextEra) { + nextAvRating = ratGen.findModelAvailabilityRecord(nextEra, + curModel.getKey(), fRec); + + int interpolationStart = Math.max(currentEra, Math.min(exactYear, curModel.introYear)); + + adjRating = curModel.calcAvailability(avRating, + equipRating, numRatingLevels, interpolationStart); + + nextRating = 0.0; + if (nextAvRating != null) { + nextRating = curModel.calcAvailability(nextAvRating, + equipRating, numRatingLevels, nextEra); + } + + if (adjRating != nextRating) { + adjRating = adjRating + + (nextRating - adjRating) * (exactYear - interpolationStart) / (nextEra - interpolationStart); + } + + } else { + // Adjust availability for +/- dynamic and intro year + adjRating = curModel.calcAvailability(avRating, equipRating, numRatingLevels, exactYear); + } + + if (adjRating <= 0) { + continue; + } + + // Adjust availability for roles. Method may return null as a filtering mechanism. + roleRating = MissionRole.adjustAvailabilityByRole(adjRating, + roles, curModel, exactYear, roleStrictness); + + if (roleRating == null || roleRating.doubleValue() <= 0) { + continue; + } + + // Calculate the weight and add it to the total + adjRating = AvailabilityRating.calcWeight(roleRating.doubleValue()); + retVal += adjRating; + + // Cache the final availability rating + weightData.put(curModel.getKey(), adjRating); + } + + return retVal; + } + + public double totalModelWeight(int era, String fKey) { FactionRecord fRec = RATGenerator.getInstance().getFaction(fKey); if (fRec == null) { logger.warn("Attempt to find totalModelWeight for non-existent faction " + fKey); @@ -64,15 +208,15 @@ public int totalModelWeight(int era, String fKey) { return totalModelWeight(era, fRec); } - public int totalModelWeight(int era, FactionRecord fRec) { - int retVal = 0; - RATGenerator rg = RATGenerator.getInstance(); + public double totalModelWeight(int era, FactionRecord fRec) { + double retVal = 0; + RATGenerator ratGen = RATGenerator.getInstance(); - for (ModelRecord mr : models) { - AvailabilityRating ar = rg.findModelAvailabilityRecord(era, - mr.getKey(), fRec); - if (ar != null) { - retVal += AvailabilityRating.calcWeight(ar.getAvailability()); + for (ModelRecord curModel : models) { + AvailabilityRating avRating = ratGen.findModelAvailabilityRecord(era, + curModel.getKey(), fRec); + if (avRating != null) { + retVal += AvailabilityRating.calcWeight(avRating.getAvailability()); } } diff --git a/megamek/src/megamek/client/ratgenerator/ModelRecord.java b/megamek/src/megamek/client/ratgenerator/ModelRecord.java index 76f0bec604c..62c666479eb 100644 --- a/megamek/src/megamek/client/ratgenerator/ModelRecord.java +++ b/megamek/src/megamek/client/ratgenerator/ModelRecord.java @@ -48,8 +48,8 @@ /** * Specific unit variants; analyzes equipment to determine suitability for - * certain types - * of missions in addition to what is formally declared in the data files. + * certain types of missions in addition to what is formally declared in the + * data files. * * @author Neoancient */ @@ -75,6 +75,7 @@ public class ModelRecord extends AbstractUnitRecord { private boolean primitive; private boolean retrotech; private boolean starLeague; + private boolean mixedTech; private int weightClass; private EntityMovementMode movementMode; @@ -146,6 +147,21 @@ public boolean isClan() { return clan; } + /** + * @return true, if unit is base IS tech and mounts Clan tech equipment + */ + public boolean isMixedISTech() { + return mixedTech; + } + + /** + * @return true, if unit is either base Clan tech, or is base IS tech and + * mounts Clan tech equipment + */ + public boolean isMixedOrClanTech() { + return clan || mixedTech; + } + /** * Unit contains at least some primitive technology, without any advanced tech. * Testing is not extensive, there may be units that are not properly flagged. @@ -159,9 +175,8 @@ public boolean isPrimitive() { /** * Unit consists of at least some primitive technology and some advanced/Star - * League/Clan - * technology. Testing is not extensive, there may be units that are not - * properly flagged. + * League/Clan technology. Testing is not extensive, there may be units that + * are not properly flagged. * * @return true, if unit contains both primitive and advanced tech */ @@ -222,17 +237,12 @@ public double getArtilleryProportion() { /** * Proportion of total weapons BV that is capable of attacking targets at longer - * ranges. - * Units with values of 0.75 or higher are mostly armed with weapons that can - * hit targets at - * 15+ hexes, have a minimum range, and potentially fire indirectly in ground - * combat; or - * reach long/extreme range in air or space combat. + * ranges. Units with values of 0.75 or higher are mostly armed with weapons + * that can hit targets at 15+ hexes, have a minimum range, and potentially fire + * indirectly in ground combat; or reach long/extreme range in air or space combat. * Complementary to getSRProportion() - where one is high and the other is low, - * the unit is - * specialized for that range bracket. If both values are similar the unit is - * well balanced - * between long and short ranged capabilities. + * the unit is specialized for that range bracket. If both values are similar + * the unit is well balanced between long and short ranged capabilities. * TODO: rename for consistency and clarity * * @return between zero (none) and 1.0 (all weapons) @@ -243,17 +253,12 @@ public double getLongRange() { /** * Proportion of total weapons BV that is limited to attacking targets at close - * range. - * Units with values of 0.75 or higher are mostly armed with weapons that have a - * long range - * of less than 12 hexes and do not have a minimum range in ground combat, or - * are limited to - * short range in air/space combat. + * range. Units with values of 0.75 or higher are mostly armed with weapons that + * have a long range of less than 12 hexes and do not have a minimum range in + * ground combat, or are limited to short range in air/space combat. * Complementary to getLongRange() - where one is high and the other is low, the - * unit is - * specialized for that range bracket. If both values are similar the unit is - * well balanced - * between long and short ranged capabilities. + * unit is specialized for that range bracket. If both values are similar the + * unit is well balanced between long and short ranged capabilities. * * @return between zero (none) and 1.0 (all weapons) */ @@ -391,8 +396,7 @@ public boolean getAntiMek() { /** * Checks the equipment carried by this unit and summarizes it in a variety of - * easy to access - * data + * easy to access data * * @param unitData Data for unit */ @@ -492,12 +496,10 @@ private void analyzeModel(MekSummary unitData) { for (int i = 0; i < unitData.getEquipmentNames().size(); i++) { // EquipmentType.get is throwing an NPE intermittently, and the only possibility - // I can see - // is that there is a null equipment name. + // I can see is that there is a null equipment name. if (null == unitData.getEquipmentNames().get(i)) { - logger.error( - "RATGenerator ModelRecord encountered null equipment name in MekSummary for " - + unitData.getName() + ", index " + i); + logger.error("RATGenerator ModelRecord encountered null equipment name" + + " in MekSummary for {}, index {}", unitData.getName(), i); continue; } EquipmentType eq = EquipmentType.get(unitData.getEquipmentNames().get(i)); @@ -510,6 +512,11 @@ private void analyzeModel(MekSummary unitData) { losTech = true; } + // If this is Clan tech on an IS base unit, set the mixed tech flag + if (!mixedTech && !clan && eq.isClan()) { + mixedTech = true; + } + if (eq instanceof WeaponType) { // Flag units that are capable of making anti-Mek attacks. Don't bother making @@ -552,23 +559,20 @@ private void analyzeModel(MekSummary unitData) { } // Check for use against airborne targets. Ignore small craft, DropShips, and - // other - // large space craft. + // other large spacecraft. if (unitType < UnitType.SMALL_CRAFT) { flakBV += getFlakBVModifier((WeaponType) eq) * eq.getBV(null) * unitData.getEquipmentQuantities().get(i); } // Check for artillery weapons. Ignore aerospace fighters, small craft, and - // large - // space craft. + // large spacecraft. if (unitType <= UnitType.CONV_FIGHTER && eq instanceof ArtilleryWeapon) { artilleryBV += eq.getBV(null) * unitData.getEquipmentQuantities().get(i); } // Don't check incendiary weapons for conventional infantry, fixed wing - // aircraft, - // and space-going units + // aircraft, and space-going units if (!incendiary && (unitType < UnitType.CONV_FIGHTER && unitType != UnitType.INFANTRY)) { if (eq instanceof FlamerWeapon || @@ -596,9 +600,8 @@ private void analyzeModel(MekSummary unitData) { } // Total up BV for weapons that require ammo. Streak-type missile systems get a - // discount. Ignore small craft, DropShips, and large space craft. Ignore - // infantry - // weapons except for field guns. + // discount. Ignore small craft, DropShips, and large spacecraft. Ignore + // infantry weapons except for field guns. if (unitType < UnitType.SMALL_CRAFT && ((WeaponType) eq).getAmmoType() > AmmoType.T_NA && !(eq instanceof InfantryWeapon)) { @@ -620,15 +623,14 @@ private void analyzeModel(MekSummary unitData) { } // Total up BV for weapons capable of attacking at the longest ranges or using - // indirect fire. Ignore small craft, DropShips, and other space craft. + // indirect fire. Ignore small craft, DropShips, and other spacecraft. if (unitType < UnitType.SMALL_CRAFT) { longRangeBV += getLongRangeModifier((WeaponType) eq) * eq.getBV(null) * unitData.getEquipmentQuantities().get(i); } // Total up BV of weapons suitable for attacking at close range. Ignore small - // craft, - // DropShips, and other space craft. Also skip anti-Mek attacks. + // craft, DropShips, and other spacecraft. Also skip anti-Mek attacks. if (unitType < UnitType.SMALL_CRAFT) { shortRangeBV += getShortRangeModifier((WeaponType) eq) * eq.getBV(null) * unitData.getEquipmentQuantities().get(i); @@ -696,9 +698,8 @@ private void analyzeModel(MekSummary unitData) { } // Calculate BV proportions for all ground units, VTOL, blue water naval, gun - // emplacements - // and fixed wing aircraft. Exclude Small craft, DropShips, and large space - // craft. + // emplacements and fixed wing aircraft. Exclude Small craft, DropShips, and + // large spacecraft. if (unitType <= UnitType.AEROSPACEFIGHTER) { if (totalWeaponBV > 0) { flakBVProportion = flakBV / totalWeaponBV; @@ -722,9 +723,8 @@ private void analyzeModel(MekSummary unitData) { /** * Units are considered primitive if they have no advanced tech and at least - * some primitive - * tech. The check is not exhaustive so some niche units may not be flagged - * properly. + * some primitive tech. The check is not exhaustive so some niche units may + * not be flagged properly. * * @param unitData Unit data * @return true if unit has primitive tech and no advanced tech @@ -824,10 +824,9 @@ private boolean isUnitPrimitive(MekSummary unitData) { /** * Checks that unit has at least one piece of primitive tech for basic unit - * components such as - * engine, frame, and armor. The check is not extensive so some units may - * include a niche item - * even though they are not flagged as such. + * components such as engine, frame, and armor. The check is not extensive + * so some units may include a niche item even though they are not flagged + * as such. * * @param unitData Unit data * @return true if unit contains primitive basic equipment @@ -880,8 +879,7 @@ private boolean unitHasPrimitiveTech(MekSummary unitData) { /** * Check if unit is built with advanced technology. This only checks the basic - * components, - * not any mounted equipment such as weapons. + * components, not any mounted equipment such as weapons. * * @param unitData unit data * @param starLeagueOnly true to only check original Star League tech - XL @@ -989,8 +987,7 @@ private double getFlakBVModifier(WeaponType checkWeapon) { double ineffective = 0.0; // Use a limited version for checking air-to-air capability, including potential - // for - // thresholding heavily armored targets + // for thresholding heavily armored targets if (unitType == UnitType.CONV_FIGHTER || unitType == UnitType.AEROSPACEFIGHTER) { if (checkWeapon.getAmmoType() == AmmoType.T_AC_LBX || checkWeapon.getAmmoType() == AmmoType.T_HAG || diff --git a/megamek/src/megamek/client/ratgenerator/RATGenerator.java b/megamek/src/megamek/client/ratgenerator/RATGenerator.java index d03e5287d3b..1c0148bef0d 100644 --- a/megamek/src/megamek/client/ratgenerator/RATGenerator.java +++ b/megamek/src/megamek/client/ratgenerator/RATGenerator.java @@ -20,6 +20,7 @@ import java.io.FileNotFoundException; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; import java.util.*; import java.util.Map.Entry; import java.util.function.Function; @@ -47,10 +48,8 @@ /** * Generates a random assignment table (RAT) dynamically based on a variety of - * criteria, - * including faction, era, unit type, weight class, equipment rating, faction - * subcommand, vehicle - * movement mode, and mission role. + * criteria, including faction, era, unit type, weight class, equipment rating, + * faction subcommand, vehicle movement mode, and mission role. * * @author Neoancient */ @@ -74,6 +73,23 @@ public class RATGenerator { private ArrayList listeners; + /** + * Minimum difference between actual percentage and desired percentage of Omni-units + * that will trigger re-balancing + */ + private static double MIN_OMNI_DIFFERENCE = 2.5; + + /** + * Minimum difference between actual percentage and desired percentage of base-Clan + * units that will trigger re-balancing + */ + private static double MIN_CLAN_DIFFERENCE = 2.5; + /** + * Minimum difference between actual percentage and desired percentage of Star League + * and advanced IS tech units that will trigger re-balancing + */ + private static double MIN_SL_DIFFERENCE = 2.5; + protected RATGenerator() { models = new HashMap<>(); chassis = new HashMap<>(); @@ -123,8 +139,10 @@ public void reloadFromDir(File dir) { rg.getEraSet().forEach(e -> rg.loadEra(e, dir)); } - public AvailabilityRating findChassisAvailabilityRecord(int era, String unit, String faction, - int year) { + public AvailabilityRating findChassisAvailabilityRecord(int era, + String unit, + String faction, + int year) { if (factions.containsKey(faction)) { return findChassisAvailabilityRecord(era, unit, factions.get(faction), year); } @@ -137,18 +155,32 @@ public AvailabilityRating findChassisAvailabilityRecord(int era, String unit, St return null; } + /** + * Return the availability rating for a given chassis. If the specific faction is not + * directly listed, the parents of the provided faction are used as a lookup. If multiple + * parent factions are provided, all of them are calculated and then averaged. + * @param era year for era + * @param unit string with chassis name + * @param fRec faction data + * @param year year to test + * @return chassis availability rating, relative to other chassis in a collection + */ public @Nullable AvailabilityRating findChassisAvailabilityRecord(int era, String unit, FactionRecord fRec, int year) { + if (fRec == null) { return null; } + AvailabilityRating retVal = null; if (chassisIndex.containsKey(era) && chassisIndex.get(era).containsKey(unit)) { + if (chassisIndex.get(era).get(unit).containsKey(fRec.getKey())) { retVal = chassisIndex.get(era).get(unit).get(fRec.getKey()); } else if (fRec.getParentFactions().size() == 1) { retVal = findChassisAvailabilityRecord(era, unit, fRec.getParentFactions().get(0), year); } else if (!fRec.getParentFactions().isEmpty()) { + ArrayList list = new ArrayList<>(); for (String alt : fRec.getParentFactions()) { AvailabilityRating ar = findChassisAvailabilityRecord(era, unit, alt, year); @@ -157,6 +189,7 @@ public AvailabilityRating findChassisAvailabilityRecord(int era, String unit, St } } retVal = mergeFactionAvailability(fRec.getKey(), list); + } else { retVal = chassisIndex.get(era).get(unit).get("General"); } @@ -169,8 +202,9 @@ public AvailabilityRating findChassisAvailabilityRecord(int era, String unit, St return null; } - public @Nullable AvailabilityRating findModelAvailabilityRecord(int era, String unit, - String faction) { + public @Nullable AvailabilityRating findModelAvailabilityRecord(int era, + String unit, + String faction) { if (factions.containsKey(faction)) { return findModelAvailabilityRecord(era, unit, factions.get(faction)); } else if (modelIndex.containsKey(era) && modelIndex.get(era).containsKey(unit)) { @@ -180,31 +214,50 @@ public AvailabilityRating findChassisAvailabilityRecord(int era, String unit, St } } - public @Nullable AvailabilityRating findModelAvailabilityRecord(int era, String unit, - @Nullable FactionRecord fRec) { + /** + * Generate the availability rating for a specific model + * @param era era designation + * @param unit string full chassis-model name + * @param fRec faction data + * @return the availability value relative to other models of the same chassis + */ + public @Nullable AvailabilityRating findModelAvailabilityRecord(int era, + String unit, + @Nullable FactionRecord fRec) { + if (null == models.get(unit)) { logger.error("Trying to find record for unknown model " + unit); return null; } else if ((fRec == null) || models.get(unit).factionIsExcluded(fRec)) { return null; } + if (modelIndex.containsKey(era) && modelIndex.get(era).containsKey(unit)) { + + // If the provided faction is directly specified, return its availability if (modelIndex.get(era).get(unit).containsKey(fRec.getKey())) { return modelIndex.get(era).get(unit).get(fRec.getKey()); } + // If the provided faction has a single parent, return its availability if (fRec.getParentFactions().size() == 1) { return findModelAvailabilityRecord(era, unit, fRec.getParentFactions().get(0)); } else if (!fRec.getParentFactions().isEmpty()) { - ArrayList list = new ArrayList<>(); - for (String alt : fRec.getParentFactions()) { - AvailabilityRating ar = findModelAvailabilityRecord(era, unit, alt); + + // If neither the faction nor a direct parent is directly specified and + // multiple parent factions are available, calculate an average between them + ArrayList avList = new ArrayList<>(); + for (String curParent : fRec.getParentFactions()) { + AvailabilityRating ar = findModelAvailabilityRecord(era, unit, curParent); if (ar != null) { - list.add(ar); + avList.add(ar); } } - return mergeFactionAvailability(fRec.getKey(), list); + return mergeFactionAvailability(fRec.getKey(), avList); + } + + // As a fallback, check for General availability return modelIndex.get(era).get(unit).get("General"); } @@ -377,34 +430,35 @@ public boolean eraIsLoaded(int era) { } /** - * Used for a faction with multiple parent factions (e.g. FC == FS + LA) to find - * the average - * availability among the parents. Based on average weight rather than av - * rating. + * Used for a faction with multiple parent factions (e.g. FC == FS + LA) + * to find the average availability among the parents. Based on average + * weight rather than av rating. * - * @param faction The faction code to use for the new AvailabilityRecord - * @param list A list of ARs for the various parent factions - * @return A new AR with the average availability code from the various - * factions. + * @param faction The faction code to use for the new AvailabilityRecord + * @param avList A list of ratings from the various parent factions + * @return A new availability rating with the average value from the various factions. */ - private AvailabilityRating mergeFactionAvailability(String faction, List list) { - if (list.isEmpty()) { + private AvailabilityRating mergeFactionAvailability(String faction, + List avList) { + + if (avList.isEmpty()) { return null; } + double totalWt = 0; int totalAdj = 0; - for (AvailabilityRating ar : list) { + + for (AvailabilityRating ar : avList) { totalWt += AvailabilityRating.calcWeight(ar.availability); totalAdj += ar.ratingAdjustment; } - AvailabilityRating retVal = list.get(0).makeCopy(faction); + AvailabilityRating retVal = avList.get(0).makeCopy(faction); - retVal.availability = (int) (AvailabilityRating.calcAvRating(totalWt / list.size())); - if (totalAdj < 0) { - retVal.ratingAdjustment = (totalAdj - 1) / list.size(); - } else { - retVal.ratingAdjustment = (totalAdj + 1) / list.size(); + retVal.availability = (int) (AvailabilityRating.calcAvRating(totalWt / avList.size())); + if (totalAdj != 0) { + retVal.ratingAdjustment = totalAdj > 0 ? 1 : -1; } + return retVal; } @@ -438,13 +492,32 @@ private Double interpolate(Number av1, Number av2, int year1, int year2, int now + (av2.doubleValue() - av1.doubleValue()) * (now - year1) / (year2 - year1); } - public List generateTable(FactionRecord fRec, int unitType, int year, - String rating, Collection weightClasses, int networkMask, - Collection movementModes, - Collection roles, int roleStrictness, - FactionRecord user) { + /** + * Generate random selection table entries, given a range of parameters + * @param fRec faction data for selecting units + * @param unitType type of unit (Mek, conventional infantry, etc.) + * @param year current game year + * @param rating equipment rating, typically A/B/C/D/F with A best and F worst + * @param weightClasses which weight classes to select, empty or null for all + * @param networkMask type of C3 system required, 0 for none + * @param movementModes which movement types to select, empty or null for all + * @param roles apply force generator roles when calculating random selection weights + * @param roleStrictness how strictly to apply roles, 0 (none) or higher (more) + * @param user used with OmniMek and salvage balancing + * @return list of entries suitable for building a random generation table, may be empty + */ + public List generateTable(FactionRecord fRec, + int unitType, + int year, + String rating, + Collection weightClasses, + int networkMask, + Collection movementModes, + Collection roles, + int roleStrictness, + FactionRecord user) { + HashMap unitWeights = new HashMap<>(); - HashMap salvageWeights = new HashMap<>(); loadYear(year); @@ -452,29 +525,26 @@ public List generateTable(FactionRecord fRec, int unitType fRec = new FactionRecord(); } - Integer early = eraSet.floor(year); - if (early == null) { - early = eraSet.first(); + Integer currentEra = eraSet.floor(year); + if (currentEra == null) { + currentEra = eraSet.first(); } - Integer late = null; + Integer nextEra = null; if (!eraSet.contains(year)) { - late = eraSet.ceiling(year); + nextEra = eraSet.ceiling(year); } - if (late == null) { - late = early; + if (nextEra == null) { + nextEra = currentEra; } /* - * Adjustments for unit rating require knowing both how many ratings are - * available + * Adjustments for unit rating require knowing both how many ratings are available * to the faction and where the rating falls within the whole. If a faction does * not have designated rating levels, it inherits those of the parent faction; - * if there are multiple parent factions the first match is used. Some very - * minor - * or generic factions do not use rating adjustments, indicated by a rating - * level - * of -1. A faction that has one rating level is a special case that always has - * the indicated rating within the parent faction's system. + * if there are multiple parent factions the first match is used. + * Some very minor or generic factions do not use rating adjustments, indicated by + * a rating level of -1. A faction that has one rating level is a special case that + * always has the indicated rating within the parent faction's system. */ int ratingLevel = -1; @@ -488,101 +558,176 @@ public List generateTable(FactionRecord fRec, int unitType ratingLevel = factionRatings.indexOf(rating); } - for (String chassisKey : chassisIndex.get(early).keySet()) { - ChassisRecord cRec = chassis.get(chassisKey); - if (cRec == null) { - logger.error("Could not locate chassis " + chassisKey); + // Iterate through all available chassis + double chassisWeightTotal = 0.0; + for (String chassisKey : chassisIndex.get(currentEra).keySet()) { + ChassisRecord curChassis = chassis.get(chassisKey); + if (curChassis == null) { + logger.error("Could not locate chassis {}", chassisKey); + continue; + } + + // Pre-production prototypes may show up one year before official introduction + if (curChassis.introYear > year + 1) { continue; } - if (Arrays.asList(UnitType.AERO, UnitType.AEROSPACEFIGHTER).contains(cRec.getUnitType())) { - // Handle ChassisRecords saved as "AERO" units as ASFs for now - cRec.setUnitType(UnitType.AEROSPACEFIGHTER); + // Handle ChassisRecords saved as "AERO" units as ASFs for now + if (Arrays.asList(UnitType.AERO, UnitType.AEROSPACEFIGHTER).contains(curChassis.getUnitType())) { + curChassis.setUnitType(UnitType.AEROSPACEFIGHTER); } - if (cRec.getUnitType() != unitType && + // Only return VTOLs when specifically requesting the unit type + if (curChassis.getUnitType() != unitType && !(unitType == UnitType.TANK - && cRec.getUnitType() == UnitType.VTOL + && curChassis.getUnitType() == UnitType.VTOL && movementModes.contains(EntityMovementMode.VTOL))) { continue; } - AvailabilityRating ar = findChassisAvailabilityRecord(early, - chassisKey, fRec, year); - if (ar == null) { + // Preliminary filtering by weight class. Most units that have a weight + // class are the same for all models although a few outliers exist, so + // just look for the first. + if (weightClasses != null && !weightClasses.isEmpty()) { + boolean validChassis = curChassis. + getModels(). + stream(). + mapToInt(ModelRecord::getWeightClass). + anyMatch(weightClasses::contains); + if (!validChassis) { + continue; + } + } + + AvailabilityRating chassisAvRating = findChassisAvailabilityRecord(currentEra, + chassisKey, fRec, year); + if (chassisAvRating == null) { continue; } - double cAv = cRec.calcAvailability(ar, ratingLevel, numRatingLevels, early); - cAv = interpolate(cAv, - cRec.calcAvailability(ar, ratingLevel, numRatingLevels, late), - Math.max(early, cRec.getIntroYear()), late, year); - if (cAv > 0) { - double totalModelWeight = cRec.totalModelWeight(early, - cRec.isOmni() ? user : fRec); - for (ModelRecord mRec : cRec.getModels()) { - if (mRec.getIntroYear() > year - || (!weightClasses.isEmpty() - && !weightClasses.contains(mRec.getWeightClass())) - || (networkMask & mRec.getNetworkMask()) != networkMask) { - continue; - } - if (!movementModes.isEmpty() && !movementModes.contains(mRec.getMovementMode())) { - continue; - } - ar = findModelAvailabilityRecord(early, mRec.getKey(), fRec); - if ((ar == null) || (ar.getAvailability() == 0)) { - continue; - } - double mAv = mRec.calcAvailability(ar, ratingLevel, numRatingLevels, early); - mAv = interpolate(mAv, - mRec.calcAvailability(ar, ratingLevel, numRatingLevels, late), - Math.max(early, mRec.getIntroYear()), late, year); - Double adjMAv = MissionRole.adjustAvailabilityByRole(mAv, roles, mRec, year, roleStrictness); - if (adjMAv != null) { - double mWt = AvailabilityRating.calcWeight(adjMAv) / totalModelWeight - * AvailabilityRating.calcWeight(cAv); - if (mWt > 0) { - unitWeights.put(mRec, mWt); + double chassisAdjRating; + + // If necessary, interpolate chassis availability between era values + if (year != currentEra && year != nextEra) { + AvailabilityRating chassisNextAvRating = findChassisAvailabilityRecord(nextEra, chassisKey, fRec, nextEra); + + // Find the chassis availability at the start of the era, or at + // intro date, including dynamic modifiers + int interpolationStart = Math.max(currentEra, Math.min(year, curChassis.introYear)); + chassisAdjRating = curChassis.calcAvailability(chassisAvRating, + ratingLevel, + numRatingLevels, + interpolationStart); + + + double chassisNextAdj = 0.0; + if (chassisNextAvRating != null) { + chassisNextAdj = curChassis.calcAvailability(chassisNextAvRating, ratingLevel, numRatingLevels, nextEra); + } + + if (chassisAdjRating != chassisNextAdj) { + chassisAdjRating = interpolate( + chassisAdjRating, + chassisNextAdj, + interpolationStart, + nextEra, + year); + } + + } else { + // Find the chassis availability taking into account +/- dynamic + // modifiers and introduction year + chassisAdjRating = curChassis.calcAvailability(chassisAvRating, + ratingLevel, numRatingLevels, year); + } + + if (chassisAdjRating > 0) { + + // Apply basic filters to models before summing the total weight + HashSet validModels = curChassis.getFilteredModels(year, + weightClasses, movementModes, networkMask); + + HashMap modelWeights = new HashMap<>(); + + double totalWeight = curChassis.totalModelWeight(validModels, + currentEra, + year, + nextEra, + curChassis.isOmni() ? user : fRec, + roles, + roleStrictness, + ratingLevel, + numRatingLevels, + modelWeights); + + if (totalWeight > 0 && !modelWeights.isEmpty()) { + double chassisWeight = AvailabilityRating.calcWeight(chassisAdjRating); + boolean hasModels = false; + for (ModelRecord curModel : validModels) { + if (!modelWeights.containsKey(curModel.getKey())) { + continue; + } + + // Overall availability is the odds of the chassis multiplied + // by the odds of the model. Note that the chassis weight total + // is factored later after all chassis are processed. + double curWeight = chassisWeight * modelWeights.get(curModel.getKey()) / totalWeight; + + // Add the random selection weight for this specific model to the tracker + if (curWeight > 0) { + unitWeights.put(curModel, curWeight); + hasModels = true; } } + + if (hasModels) { + chassisWeightTotal += chassisWeight; + } } + } } - if (unitWeights.isEmpty()) { + if (unitWeights.isEmpty() || chassisWeightTotal == 0.0) { return new ArrayList<>(); } - // If there is more than one weight class and the faction record (or parent) - // indicates a - // certain distribution of weight classes, adjust the weight value to conform to - // the given - // ratio. - if (weightClasses.size() > 1) { + // Factor chassis total into every weight + for (ModelRecord curModel : unitWeights.keySet()) { + unitWeights.merge(curModel, chassisWeightTotal, (a, b) -> 100.0 * a / b); + } + + // If there is more than one weight class being generated and the faction record + // (or parent) indicates a certain distribution of weight classes, adjust the + // generated unit weights conform to the given ratio. + if (weightClasses != null && weightClasses.size() > 1) { + // Get standard weight class distribution for faction - ArrayList wcd = fRec.getWeightDistribution(early, unitType); + ArrayList weightClassDistribution = fRec.getWeightDistribution(currentEra, + unitType); - if ((wcd != null) && !wcd.isEmpty()) { + if ((weightClassDistribution != null) && !weightClassDistribution.isEmpty()) { // Ultra-light and superheavy are too rare to warrant their own values and for // weight class distribution purposes are grouped with light and assault, // respectively. final int[] wcdIndex = { 0, 0, 1, 2, 3, 3 }; - // Find the totals of the weight for the generated table - double totalMRWeight = unitWeights.values().stream() + // Find the totals of the weights for the generated table + double totalTableWeight = unitWeights.values().stream() .mapToDouble(Double::doubleValue) .sum(); - // Find the sum of the weight distribution values for all weight classes in use. + // Find the sum of the weight distribution values for each weight + // class being called for int totalWCDWeights = weightClasses.stream() - .filter(wc -> wcdIndex[wc] < wcd.size()) - .mapToInt(wc -> wcd.get(wcdIndex[wc])) + .filter(wc -> wcdIndex[wc] < weightClassDistribution.size()) + .mapToInt(wc -> weightClassDistribution.get(wcdIndex[wc])) .sum(); if (totalWCDWeights > 0) { // Group all the models of the generated table by weight class. Function grouper = mr -> wcdIndex[mr.getWeightClass()]; - Map> weightGroups = unitWeights.keySet().stream() - .collect(Collectors.groupingBy(grouper)); + Map> weightGroups = unitWeights. + keySet(). + stream().collect(Collectors.groupingBy(grouper)); // Go through the weight class groups and adjust the table weights so the total // of each group corresponds to the distribution for this faction. @@ -591,7 +736,7 @@ public List generateTable(FactionRecord fRec, int unitType .mapToDouble(unitWeights::get) .sum(); if (totalWeight > 0) { - double adj = totalMRWeight * wcd.get(i) / (totalWeight * totalWCDWeights); + double adj = totalTableWeight * weightClassDistribution.get(i) / (totalWeight * totalWCDWeights); weightGroups.get(i).forEach(mr -> unitWeights.merge(mr, adj, (x, y) -> x * y)); } } @@ -599,53 +744,89 @@ public List generateTable(FactionRecord fRec, int unitType } } - double total = unitWeights.values().stream().mapToDouble(Double::doubleValue).sum(); - - if (fRec.getPctSalvage(early) != null) { + // If there are salvage percentages defined for the generating faction + HashMap salvageWeights = new HashMap<>(); + if (fRec.getPctSalvage(currentEra) != null) { HashMap salvageEntries = new HashMap<>(); - for (Entry entry : fRec.getSalvage(early).entrySet()) { + + // If current year is directly on an era data point take it, otherwise + // interpolate between current era and next era values. + for (Entry entry : fRec.getSalvage(currentEra).entrySet()) { salvageEntries.put(entry.getKey(), + currentEra == year ? + entry.getValue() : interpolate(entry.getValue(), - fRec.getSalvage(late).get(entry.getKey()), - early, late, year)); + fRec.getSalvage(nextEra).get(entry.getKey()), + currentEra, + nextEra, + year)); } - if (!late.equals(early)) { - for (Entry entry : fRec.getSalvage(late).entrySet()) { + // Add salvage from the next era that is not already present + if (!nextEra.equals(currentEra)) { + for (Entry entry : fRec.getSalvage(nextEra).entrySet()) { if (!salvageEntries.containsKey(entry.getKey())) { - salvageEntries.put(entry.getKey(), interpolate(0.0, - entry.getValue(), early, late, year)); + salvageEntries.put(entry.getKey(), + interpolate(0.0, + entry.getValue(), + currentEra, + nextEra, + year)); } } } - double salvage = fRec.getPctSalvage(early); - if (salvage >= 100) { - salvage = total; + // Use the total salvage percentage from the faction data to get the total + // weight of all salvage entries, from the current overall table weight. + // If a salvage percentage of 100 percent is specified (unlikely, but possible) + // then clear the existing table and regenerate everything again based purely + // on salvage. + double totalTableWeight = unitWeights.values().stream().mapToDouble(Double::doubleValue).sum(); + double overallSalvage = fRec.getPctSalvage(currentEra); + if (overallSalvage >= 100) { + overallSalvage = totalTableWeight; unitWeights.clear(); } else { - salvage = salvage * total / (100 - salvage); + overallSalvage = totalTableWeight * overallSalvage / 100.0; } + + // Break down the total salvage weight by relative weights of each + // provided salvage faction double totalFactionWeight = salvageEntries.values().stream() .mapToDouble(Double::doubleValue) .sum(); for (String fKey : salvageEntries.keySet()) { FactionRecord salvageFaction = factions.get(fKey); if (salvageFaction == null) { - logger.debug("Could not locate faction " + fKey - + " for " + fRec.getKey() + " salvage"); + logger.debug("Could not locate faction {} for {} salvage.", + fKey, fRec.getKey()); } else { - double wt = salvage * salvageEntries.get(fKey) / totalFactionWeight; - salvageWeights.put(salvageFaction, wt); + double factionSalvageWeight = overallSalvage * salvageEntries.get(fKey) / totalFactionWeight; + salvageWeights.put(salvageFaction, factionSalvageWeight); } } } - if (ratingLevel >= 0) { - adjustForRating(fRec, unitType, year, ratingLevel, unitWeights, salvageWeights, early, late); - } - - // Increase weights if necessary to keep smallest from rounding down to zero + // Adjust weights of standard table entries and salvage entries for established + // percentages of Omni-units, base Clan tech, and Star League/advanced tech. + // Only do this for Omni-capable unit types, which also covers those which are + // commonly fitted with Clan or Star League/advanced technology. + // Do not re-balance conventional infantry, battle armor, large craft, or other + // unit types. Also skip combat support and civilian specialist roles. + if (ratingLevel >= 0 && + (unitType == UnitType.MEK || + unitType == UnitType.AEROSPACEFIGHTER || + unitType == UnitType.TANK || + unitType == UnitType.VTOL) && + (roles == null || + (!roles.contains(MissionRole.SUPPORT) || + !roles.contains(MissionRole.CIVILIAN) || + !roles.contains(MissionRole.ENGINEER)))){ + adjustForRating(fRec, unitType, year, ratingLevel, unitWeights, salvageWeights, currentEra, nextEra); + } + + // Incorporate the salvage entries with the unit entries. Then re-calculate + // weights as necessary to keep the range of values between 0 and 1000. double adj = 1.0; DoubleSummaryStatistics stats = Stream .concat(salvageWeights.values().stream(), unitWeights.values().stream()) @@ -672,57 +853,190 @@ public List generateTable(FactionRecord fRec, int unitType retVal.add(new TableEntry(wt, mRec.getMekSummary())); } } + return retVal; } - private void adjustForRating(FactionRecord fRec, int unitType, int year, int rating, - Map unitWeights, - Map salvageWeights, Integer early, - Integer late) { - double total = 0.0; - double totalOmni = 0.0; - double totalClan = 0.0; - double totalSL = 0.0; + /** + * Adjust weighted random selection value based on percentage values for + * Omni-units, Clan-tech units, and Star League/advanced tech units from + * faction data. The {@code unitWeights} and {@code salvageWeights} parameters + * are modified rather than returning a single unified set. + * @param fRec faction used to generate units + * @param unitType type of unit being generated + * @param year current game year + * @param rating equipment rating based on available range, typically F (0)/D/C/B/A (4) + * @param unitWeights random frequency rates (entries for the table), excluding salvage + * @param salvageWeights random frequency rates of salvaged units by faction + * @param currentEra current era + * @param nextEra next era + */ + private void adjustForRating(FactionRecord fRec, + int unitType, + int year, + int rating, + Map unitWeights, + Map salvageWeights, + Integer currentEra, + Integer nextEra) { + + double totalWeight = 0.0; + double totalOmniWeight = 0.0; + double totalClanWeight = 0.0; + double totalSLWeight = 0.0; + double totalOtherWeight = 0.0; + + // Total the unit weight of all selected units, plus get totals + // of all Omni-units, base Clan-tech units, and Star League/advanced + // tech units for (Entry entry : unitWeights.entrySet()) { - total += entry.getValue(); + totalWeight += entry.getValue(); if (entry.getKey().isOmni()) { - totalOmni += entry.getValue(); + totalOmniWeight += entry.getValue(); } - if (entry.getKey().isClan()) { - totalClan += entry.getValue(); - } else if (entry.getKey().isSL()) { - totalSL += entry.getValue(); + if (entry.getKey().isSL()) { + totalSLWeight += entry.getValue(); + } else if (!entry.getKey().isMixedOrClanTech()) { + totalOtherWeight += entry.getValue(); } } + Double pctOmni = null; - Double pctNonOmni = null; Double pctSL = null; Double pctClan = null; Double pctOther = null; + + // Get the desired percentages from faction data, and interpolate between + // eras if needed. + // Note that vehicles do not re-balance based on Omni/non-Omni ratios. if (unitType == UnitType.MEK) { - pctOmni = interpolate(fRec.findPctTech(TechCategory.OMNI, early, rating), - fRec.findPctTech(TechCategory.OMNI, late, rating), early, late, year); - pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN, early, rating), - fRec.findPctTech(TechCategory.CLAN, late, rating), early, late, year); - pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED, early, rating), - fRec.findPctTech(TechCategory.IS_ADVANCED, late, rating), early, late, year); + pctOmni = interpolate(fRec.findPctTech(TechCategory.OMNI, currentEra, rating), + fRec.findPctTech(TechCategory.OMNI, nextEra, rating), currentEra, nextEra, year); + pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN, currentEra, rating), + fRec.findPctTech(TechCategory.CLAN, nextEra, rating), currentEra, nextEra, year); + pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED, currentEra, rating), + fRec.findPctTech(TechCategory.IS_ADVANCED, nextEra, rating), currentEra, nextEra, year); } if (unitType == UnitType.AEROSPACEFIGHTER) { - pctOmni = interpolate(fRec.findPctTech(TechCategory.OMNI_AERO, early, rating), - fRec.findPctTech(TechCategory.OMNI_AERO, late, rating), early, late, year); - pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN_AERO, early, rating), - fRec.findPctTech(TechCategory.CLAN_AERO, late, rating), early, late, year); - pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED_AERO, early, rating), - fRec.findPctTech(TechCategory.IS_ADVANCED_AERO, late, rating), early, late, year); + pctOmni = interpolate(fRec.findPctTech(TechCategory.OMNI_AERO, currentEra, rating), + fRec.findPctTech(TechCategory.OMNI_AERO, nextEra, rating), currentEra, nextEra, year); + pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN_AERO, currentEra, rating), + fRec.findPctTech(TechCategory.CLAN_AERO, nextEra, rating), currentEra, nextEra, year); + pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED_AERO, currentEra, rating), + fRec.findPctTech(TechCategory.IS_ADVANCED_AERO, nextEra, rating), currentEra, nextEra, year); } if (unitType == UnitType.TANK || unitType == UnitType.VTOL) { - pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN_VEE, early, rating), - fRec.findPctTech(TechCategory.CLAN_VEE, late, rating), early, late, year); - pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED_VEE, early, rating), - fRec.findPctTech(TechCategory.IS_ADVANCED_VEE, late, rating), early, late, year); + pctClan = interpolate(fRec.findPctTech(TechCategory.CLAN_VEE, currentEra, rating), + fRec.findPctTech(TechCategory.CLAN_VEE, nextEra, rating), currentEra, nextEra, year); + pctSL = interpolate(fRec.findPctTech(TechCategory.IS_ADVANCED_VEE, currentEra, rating), + fRec.findPctTech(TechCategory.IS_ADVANCED_VEE, nextEra, rating), currentEra, nextEra, year); + } + + // Omni percentage should never be higher than Clan percentage for + // Clan factions, and never higher than Clan plus SL for IS factions. + // This may lead to unexpected results. + if (fRec.isClan()) { + if (pctOmni != null && pctClan != null && pctOmni > pctClan) { + logger.warn("Clan faction {} Clan/SL/Omni rating has" + + " higher Omni ({}) than Clan ({}) value in era {}.", + fRec.getKey(), pctOmni, pctClan, currentEra); + } + } else { + if (pctOmni != null && pctClan != null && pctSL != null && pctOmni > pctClan + pctSL) { + logger.warn("Non-Clan faction {} Clan/SL/Omni rating has" + + " higher Omni ({}) than Clan ({}) + SL ({}) value in era {}.", + fRec.getKey(), pctOmni, pctClan, pctSL, currentEra); + } + } + + // Adjust Omni-unit percentage by margin values from faction data + if (pctOmni != null) { + Double omniMargin = interpolate(fRec.getOmniMargin(currentEra), fRec.getOmniMargin(nextEra), + currentEra, nextEra, year); + + if (omniMargin != null && omniMargin > 0) { + double pct = 100.0 * totalOmniWeight / totalWeight; + if (pct < pctOmni - omniMargin) { + pctOmni -= omniMargin; + } else if (pct > pctOmni + omniMargin) { + pctOmni += omniMargin; + } + } + } - /* Adjust for lack of precision in post-FM:Updates extrapolations */ + double totalWeightPostMod = 0.0; + // Only balance Meks and aerospace for Omni/non-Omni ratios + if ((unitType == UnitType.MEK || unitType == UnitType.AEROSPACEFIGHTER) && pctOmni != null) { + + // Get the difference between the ideal Omni level and the current one + double omniPctDifference = pctOmni - (100.0 * totalOmniWeight / totalWeight); + + // If there are not enough or too many Omni-units based on the faction data, + // re-balance all the weights to bring them back into line. If the faction + // data specifies Omni-units but none are present, nothing can be done. + if (Math.abs(omniPctDifference) > MIN_OMNI_DIFFERENCE && totalOmniWeight > 0.0 && pctOmni >= 0.0) { + + // Total weight of non-Omni units. Sign is deliberately inverted + // so weights of non-Omni units are moved in opposite direction from + // Omni units. + double totalNonOmniWeight = totalOmniWeight - totalWeight; + + // Apply the weight modifications proportionally, using the unit weight relative + // to the weight total of either Omni or non-Omni as appropriate + double curWeight; + totalClanWeight = 0.0; + totalSLWeight = 0.0; + totalOtherWeight = 0.0; + double totalOmniWeightPostMod = 0.0; + for (ModelRecord curModel : unitWeights.keySet()) { + curWeight = unitWeights.get(curModel); + + if (curModel.isOmni()) { + if (pctOmni > 0.0) { + curWeight = curWeight + curWeight * omniPctDifference / totalOmniWeight; + } else { + curWeight = 0.0; + } + } else { + if (pctOmni < 100.0) { + curWeight = curWeight + curWeight * omniPctDifference / totalNonOmniWeight; + } else { + curWeight = 0.0; + } + } + + unitWeights.put(curModel, curWeight); + totalWeightPostMod += curWeight; + + // Re-calculate total weights of the various categories + if (curModel.isMixedOrClanTech()) { + totalClanWeight += curWeight; + } else if (curModel.isSL()) { + totalSLWeight += curWeight; + } else { + totalOtherWeight += curWeight; + } + if (curModel.isOmni()) { + totalOmniWeightPostMod += curWeight; + } + + } + + if (totalOmniWeightPostMod > 0.0) { + totalOmniWeight = totalOmniWeightPostMod; + } + + } + + if (totalWeightPostMod > 0.0) { + totalWeight = totalWeightPostMod; + } + + } + + // Use margin values from the faction data to adjust for lack of precision + // in post-FM:Updates era extrapolations of Clan and Star League ratios if (pctSL != null || pctClan != null) { pctOther = 100.0; if (pctSL != null) { @@ -732,11 +1046,11 @@ private void adjustForRating(FactionRecord fRec, int unitType, int year, int rat if (pctClan != null) { pctOther -= pctClan; } - Double techMargin = interpolate(fRec.getTechMargin(early), fRec.getTechMargin(late), - early, late, year); + Double techMargin = interpolate(fRec.getTechMargin(currentEra), + fRec.getTechMargin(nextEra), currentEra, nextEra, year); if (techMargin != null && techMargin > 0) { if (pctClan != null) { - double pct = 100.0 * totalClan / total; + double pct = 100.0 * totalClanWeight / totalWeight; if (pct < pctClan - techMargin) { pctClan -= techMargin; } else if (pct > pctClan + techMargin) { @@ -745,7 +1059,7 @@ private void adjustForRating(FactionRecord fRec, int unitType, int year, int rat } if (pctSL != null) { - double pct = 100.0 * totalSL / total; + double pct = 100.0 * totalSLWeight / totalWeight; if (pct < pctSL - techMargin) { pctSL -= techMargin; } else if (pct > pctSL + techMargin) { @@ -754,22 +1068,22 @@ private void adjustForRating(FactionRecord fRec, int unitType, int year, int rat } } - Double upgradeMargin = interpolate(fRec.getUpgradeMargin(early), - fRec.getUpgradeMargin(late), early, late, year); + Double upgradeMargin = interpolate(fRec.getUpgradeMargin(currentEra), + fRec.getUpgradeMargin(nextEra), currentEra, nextEra, year); if ((upgradeMargin != null) && (upgradeMargin > 0)) { - double pct = 100.0 * (total - totalClan - totalSL) / total; + double pct = 100.0 * (totalWeight - totalClanWeight - totalSLWeight) / totalWeight; if (pct < pctOther - upgradeMargin) { pctOther -= upgradeMargin; } else if (pct > pctOther + upgradeMargin) { pctOther += upgradeMargin; } - /* - * If clan, sl, and other are all adjusted, the values probably - * don't add up to 100, which is fine unless the upgradeMargin is - * <= techMargin. Then pctOther is more certain, and we adjust - * the values of clan and sl to keep the value of "other" equal to - * a percentage. - */ + + // If Clan-tech, Star League tech, and Other are all adjusted, the + // values probably don't add up to 100. This is acceptable unless + // the upgradeMargin is less than techMargin. Then pctOther is more + // certain, and we adjust the values of Clan-tech and Star League + // tech to keep the value of Other equal to a percentage. + if (techMargin != null) { if (upgradeMargin <= techMargin) { if (pctClan == null || pctClan == 0) { @@ -782,62 +1096,233 @@ private void adjustForRating(FactionRecord fRec, int unitType, int year, int rat } } } + } } - if (pctOmni != null) { - Double omniMargin = interpolate(fRec.getOmniMargin(early), fRec.getOmniMargin(late), - early, late, year); - if ((omniMargin != null) && (omniMargin > 0)) { - double pct = 100.0 * totalOmni / total; - if (pct < pctOmni - omniMargin) { - pctOmni -= omniMargin; - } else if (pct > pctOmni + omniMargin) { - pctOmni += omniMargin; + + // Re-balance Star League/advanced IS tech designs against Clan and + // low-tech units + if (pctSL != null) { + + double slPctDifference = pctSL - (100.0 * totalSLWeight / totalWeight); + if (Math.abs(slPctDifference) > MIN_SL_DIFFERENCE && totalSLWeight > 0.0) { + + // Total weight of non-Star League/advanced units. Sign is deliberately + // inverted so weights of non-SL units are moved in opposite direction from + // SL units. + double totalNonSLWeight = totalSLWeight - totalWeight; + + // Apply the weight modifications proportionally, using the unit weight relative + // to the weight total of either Clan or Other as appropriate + double curWeight; + totalClanWeight = 0.0; + totalOtherWeight = 0.0; + totalOmniWeight = 0.0; + totalWeightPostMod = 0.0; + double totalSLWeightPostMod = 0.0; + for (ModelRecord curModel : unitWeights.keySet()) { + curWeight = unitWeights.get(curModel); + + if (curModel.isSL()) { + if (pctSL > 0.0) { + curWeight = curWeight + curWeight * slPctDifference / totalSLWeight; + } else { + curWeight = 0.0; + } + } else { + if (pctSL < 100.0) { + curWeight = curWeight + curWeight * slPctDifference / totalNonSLWeight; + } else { + curWeight = 0.0; + } + } + + unitWeights.put(curModel, curWeight); + totalWeightPostMod += curWeight; + + // Re-calculate total weights of the various categories + if (curModel.isMixedOrClanTech()) { + totalClanWeight += curWeight; + } else if (curModel.isSL()) { + totalSLWeightPostMod += curWeight; + } else { + totalOtherWeight += curWeight; + } + if (curModel.isOmni()) { + totalOmniWeight += curWeight; + } + } - } - pctNonOmni = 100.0 - pctOmni; - } - - // For non-Clan factions, the amount of salvage from Clan factions is part of - // the overall - // Clan percentage. - if (!fRec.isClan() && (pctClan != null) && (totalClan > 0)) { - double clanSalvage = salvageWeights.keySet().stream().filter(FactionRecord::isClan) - .mapToDouble(salvageWeights::get).sum(); - total += clanSalvage; - totalClan += clanSalvage; - for (FactionRecord fr : salvageWeights.keySet()) { - if (fr.isClan()) { - salvageWeights.put(fr, salvageWeights.get(fr) - * (pctClan / 100.0) * (total / totalClan)); + + if (totalWeightPostMod > 0.0) { + totalWeight = totalWeightPostMod; + } + if (totalSLWeightPostMod > 0.0) { + totalSLWeight = totalSLWeightPostMod; } + } + } - double totalOther = total - totalClan - totalSL; - for (ModelRecord mRec : unitWeights.keySet()) { - if (pctOmni != null && mRec.isOmni() && totalOmni < total) { - unitWeights.put(mRec, unitWeights.get(mRec) * (pctOmni / 100.0) * (total / totalOmni)); - } - if (pctNonOmni != null && !mRec.isOmni() && totalOmni > 0) { - unitWeights.put(mRec, unitWeights.get(mRec) * (pctNonOmni / 100.0) * (total / (total - totalOmni))); + + // Re-balance Clan designs against SL and low-tech units to match + // faction data. + double clanSalvageWeight = 0.0; + if (pctClan != null) { + + // Non-Clan factions count salvage weights from Clan factions as Clan tech + if (!fRec.isClan()) { + clanSalvageWeight = salvageWeights. + keySet(). + stream(). + filter(FactionRecord::isClan). + mapToDouble(salvageWeights::get). + sum(); } - if (pctSL != null && mRec.isSL() - && totalSL > 0) { - unitWeights.put(mRec, unitWeights.get(mRec) * (pctSL / 100.0) * (total / totalSL)); + + double clanPctDifference = pctClan - (100.0 * Math.min(totalWeight, totalClanWeight + clanSalvageWeight) / totalWeight); + + if (Math.abs(clanPctDifference) > MIN_CLAN_DIFFERENCE && totalClanWeight > 0.0) { + + // Total weight of non-Clan units, including salvage if appropriate. + // Sign is deliberately inverted so weights of non-Clan units are moved + // in opposite direction from Clan units. + double totalNonClanWeight = Math.min(totalWeight, totalClanWeight + clanSalvageWeight) - totalWeight; + + // Apply the weight modifications proportionally, using the unit weight relative + // to the weight total of either Star League or Other as appropriate + double curWeight; + totalSLWeight = 0.0; + totalOtherWeight = 0.0; + totalOmniWeight = 0.0; + totalWeightPostMod = 0.0; + double totalClanWeightPostMod = 0.0; + for (ModelRecord curModel : unitWeights.keySet()) { + curWeight = unitWeights.get(curModel); + + if (curModel.isMixedOrClanTech()) { + if (pctClan > 0.0) { + curWeight = curWeight + curWeight * clanPctDifference / totalClanWeight; + } else { + curWeight = 0.0; + } + } else { + if (pctClan < 100.0) { + curWeight = curWeight + curWeight * clanPctDifference / totalNonClanWeight; + } else { + curWeight = 0.0; + } + } + + unitWeights.put(curModel, curWeight); + totalWeightPostMod += curWeight; + + // Re-calculate total weights of the various categories + if (curModel.isMixedOrClanTech()) { + totalClanWeightPostMod += curWeight; + } else if (curModel.isSL()) { + totalSLWeight += curWeight; + } else { + totalOtherWeight += curWeight; + } + if (curModel.isOmni()) { + totalOmniWeight += curWeight; + } + + } + + if (totalWeightPostMod > 0.0) { + totalWeight = totalWeightPostMod; + } + if (totalClanWeightPostMod > 0.0) { + totalClanWeight = totalClanWeightPostMod; + } + } - if (pctClan != null && mRec.isClan() - && totalClan > 0) { - unitWeights.put(mRec, unitWeights.get(mRec) * (pctClan / 100.0) * (total / totalClan)); + + } + + // If Clan and Star League/advanced IS percentages leave no allowance + // for Other/low-tech then remove them using weight of 0.0 + + if (pctSL != null && pctClan != null && + (pctOther == 0.0 || pctSL + pctClan >= 100.0)) { + + double pctOtherDifference = pctOther - 100.0 * totalOtherWeight / totalWeight; + double totalAdvancedWeight = totalOtherWeight - totalWeight; + + double curWeight; + totalOmniWeight = 0.0; + totalClanWeight = 0.0; + totalSLWeight = 0.0; + totalOtherWeight = 0.0; + totalWeightPostMod = 0.0; + for (ModelRecord curModel : unitWeights.keySet()) { + curWeight = unitWeights.get(curModel); + + if (!curModel.isSL() && !curModel.isMixedOrClanTech() && curWeight > 0.0) { + curWeight = 0.0; + } else { + curWeight = curWeight + curWeight * pctOtherDifference / totalAdvancedWeight; + } + + unitWeights.put(curModel, curWeight); + totalWeightPostMod += curWeight; + + // Re-calculate total weights of the various categories + if (curModel.isMixedOrClanTech()) { + totalClanWeight += curWeight; + } else if (curModel.isSL()) { + totalSLWeight += curWeight; + } else { + totalOtherWeight += curWeight; + } + if (curModel.isOmni()) { + totalOmniWeight += curWeight; + } } - if (pctOther != null && pctOther > 0 && !mRec.isClan() && !mRec.isSL()) { - unitWeights.put(mRec, unitWeights.get(mRec) * (pctOther / 100.0) - * (total / totalOther)); + + if (totalWeightPostMod > 0.0) { + totalWeight = totalWeightPostMod; } } - double multiplier = total / unitWeights.values().stream().mapToDouble(Double::doubleValue).sum(); - for (ModelRecord mRec : unitWeights.keySet()) { - unitWeights.merge(mRec, multiplier, (a, b) -> a * b); + + // Check each of the weight totals against the faction specified percentages + // and log any that are significantly different + DecimalFormat pctFormatter = new DecimalFormat("#.##"); + if (pctOmni != null && + Math.abs(pctOmni - (100.0 * totalOmniWeight / totalWeight)) > MIN_OMNI_DIFFERENCE) { + logger.info("Faction {} {} Omni percentage ({}) differs significantly from" + + " faction C/SL/O data ({}) in year {}.", + fRec.getKey(), + UnitType.getTypeName(unitType), + pctFormatter.format(100.0 * totalOmniWeight / totalWeight), + pctOmni, + year); + } + if (pctSL != null && + Math.abs(pctSL - (100.0 * totalSLWeight / totalWeight)) > MIN_SL_DIFFERENCE) { + logger.info("Faction {} {} Star League/advanced IS percentage ({}) differs" + + " significantly from faction C/SL/O data ({}) in year {}.", + fRec.getKey(), + UnitType.getTypeName(unitType), + pctFormatter.format(100.0 * totalSLWeight / totalWeight), + pctSL, + year); + } + if (pctClan != null && + Math.abs(pctClan - + (100.0 * Math.min(totalWeight, totalClanWeight + clanSalvageWeight) / totalWeight) + ) > MIN_CLAN_DIFFERENCE) { + logger.info("Faction {} {} Clan percentage ({}) differs significantly from" + + " faction C/SL/O data ({}) in year {}.", + fRec.getKey(), + UnitType.getTypeName(unitType), + pctFormatter.format(100.0 * Math.min(totalWeight, totalClanWeight + clanSalvageWeight) / totalWeight), + pctClan, + year); } + } public void dispose() { @@ -885,8 +1370,8 @@ private synchronized void initialize(File dir) { /** * If the year is equal to one of the era marks, it loads that era. If it is - * between two, it - * loads eras on both sides. Otherwise, just load the closest era. + * between two, it loads eras on both sides. Otherwise, just load the + * closest era. */ public void loadYear(final int year) { if (getEraSet().isEmpty()) { @@ -1022,10 +1507,8 @@ private synchronized void loadEra(int era, File dir) { /** * Creates model and chassis records for all units that don't already have - * entries. This should - * only be called after all availability records are loaded, otherwise they will - * be overwritten. - * + * entries. This should only be called after all availability records are + * loaded, otherwise they will be overwritten. * Used for editing. */ public void initRemainingUnits() {