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() {