diff --git a/megamek/src/megamek/client/ui/entityreadout/ReadoutUtils.java b/megamek/src/megamek/client/ui/entityreadout/ReadoutUtils.java index 90cabcca70c..37f2060a66d 100644 --- a/megamek/src/megamek/client/ui/entityreadout/ReadoutUtils.java +++ b/megamek/src/megamek/client/ui/entityreadout/ReadoutUtils.java @@ -248,9 +248,15 @@ private static void addBayAmmoList(WeaponMounted mounted, TableElement wpnTable) static boolean hideMisc(MiscMounted mounted, Entity entity) { String name = mounted.getName(); + // Check if this is a cockpit modification that should always be shown (e.g., DNI, EI Interface) + boolean isCockpitModification = mounted.getType().hasFlag(MiscType.F_DNI_COCKPIT_MOD) + || mounted.getType().hasFlag(MiscType.F_EI_INTERFACE) + || mounted.getType().hasFlag(MiscType.F_DAMAGE_INTERRUPT_CIRCUIT); return (((mounted.getLocation() == Entity.LOC_NONE) // Meks can have zero-slot equipment in LOC_NONE that needs to be shown. - && (!(entity instanceof Mek) || mounted.getNumCriticalSlots() > 0))) + // Also show cockpit modifications for all unit types. + && (!(entity instanceof Mek) || mounted.getNumCriticalSlots() > 0) + && !isCockpitModification)) || name.contains("Jump Jet") || (name.contains("CASE") && !name.contains("II") && entity.isClan()) || (name.contains("Heat Sink") && !name.contains("Radical")) diff --git a/megamek/src/megamek/common/cost/MekCostCalculator.java b/megamek/src/megamek/common/cost/MekCostCalculator.java index 6a4581f6a1e..eb7f3fee147 100644 --- a/megamek/src/megamek/common/cost/MekCostCalculator.java +++ b/megamek/src/megamek/common/cost/MekCostCalculator.java @@ -45,7 +45,7 @@ public class MekCostCalculator { public static double calculateCost(Mek mek, CalculationReport costReport, boolean ignoreAmmo) { - double[] costs = new double[18 + mek.locations()]; + double[] costs = new double[17 + mek.locations()]; int i = 0; double cockpitCost = switch (mek.getCockpitType()) { @@ -119,12 +119,6 @@ public static double calculateCost(Mek mek, CalculationReport costReport, boolea // cost of sinks costs[i++] = sinkCost * (mek.heatSinks() - freeSinks); costs[i++] = mek.hasFullHeadEject() ? 1725000 : 0; - // Damage Interrupt Circuit (IO p.39) costs 150 C-bills per pilot seat - int dicCost = 0; - if (mek.hasDamageInterruptCircuit() && (mek.getCrew() != null)) { - dicCost = 150 * mek.getCrew().getCrewType().getCrewSlots(); - } - costs[i++] = dicCost; // armored components int armoredCrits = 0; for (int j = 0; j < mek.locations(); j++) { diff --git a/megamek/src/megamek/common/equipment/MiscType.java b/megamek/src/megamek/common/equipment/MiscType.java index e4d1b9505ce..24ec4e10d58 100644 --- a/megamek/src/megamek/common/equipment/MiscType.java +++ b/megamek/src/megamek/common/equipment/MiscType.java @@ -1058,6 +1058,13 @@ public double getCost(Entity entity, boolean isArmored, int loc, double size) { costValue = size * 10000; } else if (hasFlag(F_RAM_PLATE)) { costValue = getTonnage(entity, loc) * 10000; + } else if (hasFlag(F_DAMAGE_INTERRUPT_CIRCUIT)) { + // DIC costs 150 C-bills per pilot seat (IO p.39) + if (entity.getCrew() != null) { + costValue = 150 * entity.getCrew().getCrewType().getCrewSlots(); + } else { + costValue = 150; // Default to 1 seat if no crew assigned + } } if (isArmored) { @@ -4726,7 +4733,8 @@ public static MiscType createDNICockpitModification() { .setISAdvancement(3052, 3055, DATE_NONE, DATE_NONE, DATE_NONE) .setISApproximate(false, false, false, false, false) .setPrototypeFactions(Faction.FS) - .setProductionFactions(Faction.WB); + .setProductionFactions(Faction.WB) + .setStaticTechLevel(SimpleTechLevel.ADVANCED); return misc; } @@ -4742,7 +4750,7 @@ public static MiscType createDamageInterruptCircuit() { misc.tonnage = 0; misc.criticalSlots = 0; - misc.cost = 150; // 150 C-bills per pilot seat (handled in cost calculator) + misc.cost = EquipmentType.COST_VARIABLE; // 150 C-bills per pilot seat (IO p.39) misc.hittable = false; misc.flags = misc.flags.or(F_MEK_EQUIPMENT, F_DAMAGE_INTERRUPT_CIRCUIT); @@ -4754,7 +4762,8 @@ public static MiscType createDamageInterruptCircuit() { .setAvailability(AvailabilityValue.X, AvailabilityValue.X, AvailabilityValue.F, AvailabilityValue.F) .setISAdvancement(3055, DATE_NONE, DATE_NONE, DATE_NONE, DATE_NONE) .setISApproximate(true, false, false, false, false) - .setPrototypeFactions(Faction.LC); + .setPrototypeFactions(Faction.LC) + .setStaticTechLevel(SimpleTechLevel.EXPERIMENTAL); return misc; } diff --git a/megamek/src/megamek/common/interfaces/ITechManager.java b/megamek/src/megamek/common/interfaces/ITechManager.java index 5337ca86708..12ef392ac83 100644 --- a/megamek/src/megamek/common/interfaces/ITechManager.java +++ b/megamek/src/megamek/common/interfaces/ITechManager.java @@ -110,18 +110,22 @@ && unofficialNoYear()) { Faction faction = getTechFaction(); boolean clanTech = useClanTechBase(); + // Use getGameYear() for availability checks - this respects the "Use Game Year" setting + // which allows equipment available by the configured game year, not just the unit's intro year + int yearForAvailability = getGameYear(); + int isIntroDate = tech.getIntroductionDate(false); int clanIntroDate = tech.getIntroductionDate(true); - boolean introducedIS = (isIntroDate != ITechnology.DATE_NONE) && (isIntroDate <= getTechIntroYear()); - boolean introducedClan = (clanIntroDate != ITechnology.DATE_NONE) && (clanIntroDate <= getTechIntroYear()); - boolean extinctIS = tech.isExtinct(getTechIntroYear(), false); - boolean extinctClan = tech.isExtinct(getTechIntroYear(), true); + boolean introducedIS = (isIntroDate != ITechnology.DATE_NONE) && (isIntroDate <= yearForAvailability); + boolean introducedClan = (clanIntroDate != ITechnology.DATE_NONE) && (clanIntroDate <= yearForAvailability); + boolean extinctIS = tech.isExtinct(yearForAvailability, false); + boolean extinctClan = tech.isExtinct(yearForAvailability, true); // A little bit of hard-coded universe detail if ((faction == Faction.CS) && extinctIS && (isIntroDate != ITechnology.DATE_NONE) - && (tech.getBaseAvailability(ITechnology.getTechEra(getTechIntroYear())).getIndex() + && (tech.getBaseAvailability(ITechnology.getTechEra(yearForAvailability)).getIndex() < AvailabilityValue.X.getIndex()) - && isIntroDate <= getTechIntroYear()) { + && isIntroDate <= yearForAvailability) { // ComStar has access to Star League tech that is otherwise extinct in the Inner Sphere as if TH, // unless it has an availability of X (which is SLDF Royal equipment). extinctIS = false; @@ -138,7 +142,7 @@ && unofficialNoYear()) { if (useMixedTech()) { if ((!introducedIS && !introducedClan) || (!showExtinct() - && (tech.isExtinct(getTechIntroYear())))) { + && (tech.isExtinct(yearForAvailability)))) { return false; } else if (useVariableTechLevel()) { // If using tech progression with mixed tech, we pass if either IS or Clan meets the required level diff --git a/megamek/src/megamek/common/loaders/BLKAeroSpaceFighterFile.java b/megamek/src/megamek/common/loaders/BLKAeroSpaceFighterFile.java index e932a693c73..334d098f471 100644 --- a/megamek/src/megamek/common/loaders/BLKAeroSpaceFighterFile.java +++ b/megamek/src/megamek/common/loaders/BLKAeroSpaceFighterFile.java @@ -208,6 +208,7 @@ public Entity getEntity() throws EntityLoadingException { a.setArmorTonnage(a.getArmorWeight()); loadQuirks(a); + loadSlotlessEquipment(a); return a; } diff --git a/megamek/src/megamek/common/loaders/BLKBattleArmorFile.java b/megamek/src/megamek/common/loaders/BLKBattleArmorFile.java index 06387dda919..e5168262d69 100644 --- a/megamek/src/megamek/common/loaders/BLKBattleArmorFile.java +++ b/megamek/src/megamek/common/loaders/BLKBattleArmorFile.java @@ -175,6 +175,7 @@ public Entity getEntity() throws EntityLoadingException { } t.setArmorTonnage(t.getArmorWeight()); loadQuirks(t); + loadSlotlessEquipment(t); return t; } diff --git a/megamek/src/megamek/common/loaders/BLKConvFighterFile.java b/megamek/src/megamek/common/loaders/BLKConvFighterFile.java index 3a53e05f319..e5a5c8131ff 100644 --- a/megamek/src/megamek/common/loaders/BLKConvFighterFile.java +++ b/megamek/src/megamek/common/loaders/BLKConvFighterFile.java @@ -169,6 +169,7 @@ public Entity getEntity() throws EntityLoadingException { a.setArmorTonnage(a.getArmorWeight()); loadQuirks(a); + loadSlotlessEquipment(a); return a; } diff --git a/megamek/src/megamek/common/loaders/BLKFile.java b/megamek/src/megamek/common/loaders/BLKFile.java index fa6b3696b7e..94a06f8278a 100644 --- a/megamek/src/megamek/common/loaders/BLKFile.java +++ b/megamek/src/megamek/common/loaders/BLKFile.java @@ -97,6 +97,7 @@ public class BLKFile { private static final int TECH_CLAN_BASE = 1 << 1; static final String BLK_EXTRA_SEATS = "extra_seats"; + static final String BLK_SLOTLESS_EQUIPMENT = "slotless_equipment"; /** * If a vehicular grenade launcher does not have a facing provided, assign a default facing. The vehicle location @@ -346,6 +347,44 @@ protected void loadEquipment(Entity t, String sName, int nLoc) throws EntityLoad } } + /** + * Loads slotless equipment (equipment at LOC_NONE) such as cockpit modifications. + * This equipment has no location but still needs to be saved and loaded. + * + * @param t the entity to load equipment into + * @throws EntityLoadingException if the equipment cannot be loaded + */ + protected void loadSlotlessEquipment(Entity t) throws EntityLoadingException { + String[] saEquip = dataFile.getDataAsString(BLK_SLOTLESS_EQUIPMENT); + if (saEquip == null) { + return; + } + + String prefix = t.isClan() ? "Clan " : "IS "; + + for (String s : saEquip) { + if (s == null || s.isBlank()) { + continue; + } + String equipName = s.trim(); + + EquipmentType etype = EquipmentType.get(equipName); + if (etype == null) { + etype = EquipmentType.get(prefix + equipName); + } + + if (etype != null) { + try { + t.addEquipment(etype, Entity.LOC_NONE); + } catch (LocationFullException ex) { + throw new EntityLoadingException(ex.getMessage()); + } + } else if (!equipName.isBlank()) { + t.addFailedEquipment(equipName); + } + } + } + protected void loadSVArmor(Entity sv) throws EntityLoadingException { boolean patchworkArmor = dataFile.exists("armor_type") && dataFile.getDataAsInt("armor_type")[0] == EquipmentType.T_ARMOR_PATCHWORK; @@ -875,6 +914,19 @@ public static BuildingBlock getBlock(Entity t) throws EntitySavingException { for (int i = 0; i < numLocs; i++) { blk.writeBlockData(t.getLocationName(i) + " Equipment", eq.get(i)); } + + // Write slotless equipment (LOC_NONE) - e.g., cockpit modifications like DNI + Vector slotlessEquipment = new Vector<>(); + for (Mounted m : t.getEquipment()) { + if (m.getLocation() == Entity.LOC_NONE && !m.isWeaponGroup() && !m.isAPMMounted() + && !(m.getType() instanceof InfantryAttack) + && !(m.getType() instanceof BayWeapon)) { + slotlessEquipment.add(encodeEquipmentLine(m)); + } + } + if (!slotlessEquipment.isEmpty()) { + blk.writeBlockData(BLK_SLOTLESS_EQUIPMENT, slotlessEquipment); + } if (!t.hasPatchworkArmor() && ArmorType.forEntity(t).hasFlag(MiscType.F_SUPPORT_VEE_BAR_ARMOR)) { blk.writeBlockData("barrating", t.getBARRating(1)); } diff --git a/megamek/src/megamek/common/loaders/BLKFixedWingSupportFile.java b/megamek/src/megamek/common/loaders/BLKFixedWingSupportFile.java index 2cf0fdbe494..6ac55693e20 100644 --- a/megamek/src/megamek/common/loaders/BLKFixedWingSupportFile.java +++ b/megamek/src/megamek/common/loaders/BLKFixedWingSupportFile.java @@ -171,6 +171,7 @@ public Entity getEntity() throws EntityLoadingException { } loadQuirks(a); + loadSlotlessEquipment(a); return a; } diff --git a/megamek/src/megamek/common/loaders/BLKLargeSupportTankFile.java b/megamek/src/megamek/common/loaders/BLKLargeSupportTankFile.java index c5eabdb3ff9..de23c454317 100644 --- a/megamek/src/megamek/common/loaders/BLKLargeSupportTankFile.java +++ b/megamek/src/megamek/common/loaders/BLKLargeSupportTankFile.java @@ -207,6 +207,7 @@ public Entity getEntity() throws EntityLoadingException { } } loadQuirks(t); + loadSlotlessEquipment(t); resetCrew(t); diff --git a/megamek/src/megamek/common/loaders/BLKSupportTankFile.java b/megamek/src/megamek/common/loaders/BLKSupportTankFile.java index f6fc4ea8224..7b045891f64 100644 --- a/megamek/src/megamek/common/loaders/BLKSupportTankFile.java +++ b/megamek/src/megamek/common/loaders/BLKSupportTankFile.java @@ -206,6 +206,7 @@ public Entity getEntity() throws EntityLoadingException { } } loadQuirks(t); + loadSlotlessEquipment(t); resetCrew(t); diff --git a/megamek/src/megamek/common/loaders/BLKSupportVTOLFile.java b/megamek/src/megamek/common/loaders/BLKSupportVTOLFile.java index a7ad6d3f2ca..23e96e18097 100644 --- a/megamek/src/megamek/common/loaders/BLKSupportVTOLFile.java +++ b/megamek/src/megamek/common/loaders/BLKSupportVTOLFile.java @@ -197,6 +197,7 @@ public Entity getEntity() throws EntityLoadingException { t.setBaseChassisFireConWeight((dataFile.getDataAsDouble("baseChassisFireConWeight")[0])); } loadQuirks(t); + loadSlotlessEquipment(t); return t; } diff --git a/megamek/src/megamek/common/loaders/BLKTankFile.java b/megamek/src/megamek/common/loaders/BLKTankFile.java index e9682d68f88..bd0e52c0ad6 100644 --- a/megamek/src/megamek/common/loaders/BLKTankFile.java +++ b/megamek/src/megamek/common/loaders/BLKTankFile.java @@ -271,6 +271,7 @@ public Entity getEntity() throws EntityLoadingException { } t.recalculateTechAdvancement(); loadQuirks(t); + loadSlotlessEquipment(t); resetCrew(t); diff --git a/megamek/src/megamek/common/loaders/BLKVTOLFile.java b/megamek/src/megamek/common/loaders/BLKVTOLFile.java index 32d51312577..6731bae51f4 100644 --- a/megamek/src/megamek/common/loaders/BLKVTOLFile.java +++ b/megamek/src/megamek/common/loaders/BLKVTOLFile.java @@ -192,6 +192,7 @@ public Entity getEntity() throws EntityLoadingException { t.setBaseChassisSponsonPintleWeight(dataFile.getDataAsDouble("baseChassisSponsonPintleWeight")[0]); } loadQuirks(t); + loadSlotlessEquipment(t); return t; } } diff --git a/megamek/src/megamek/common/templates/AeroTROView.java b/megamek/src/megamek/common/templates/AeroTROView.java index e92d3f28b7b..e54912931bc 100644 --- a/megamek/src/megamek/common/templates/AeroTROView.java +++ b/megamek/src/megamek/common/templates/AeroTROView.java @@ -43,6 +43,7 @@ import megamek.common.Messages; import megamek.common.equipment.AmmoMounted; import megamek.common.equipment.AmmoType; +import megamek.common.equipment.EquipmentType; import megamek.common.equipment.MiscType; import megamek.common.equipment.Mounted; import megamek.common.equipment.WeaponMounted; @@ -327,4 +328,15 @@ protected void addCrewEntry(String stringKey, int count) { } } + @Override + protected boolean skipMount(Mounted mount, boolean includeAmmo) { + if (mount.getLocation() == Entity.LOC_NONE) { + // Skip armor, structure, and CASE. Show cockpit modifications like DNI. + return mount.getType().hasFlag(MiscType.F_CASE) + || EquipmentType.isArmorType(mount.getType()) + || EquipmentType.isStructureType(mount.getType()); + } + return super.skipMount(mount, includeAmmo); + } + } diff --git a/megamek/src/megamek/common/templates/BattleArmorTROView.java b/megamek/src/megamek/common/templates/BattleArmorTROView.java index adfd30131e9..4d7b8ef50ad 100644 --- a/megamek/src/megamek/common/templates/BattleArmorTROView.java +++ b/megamek/src/megamek/common/templates/BattleArmorTROView.java @@ -167,9 +167,17 @@ private int addBAEquipment() { int nameWidth = 30; for (final Mounted m : ba.getEquipment()) { if (m.isAPMMounted() || (m.getType() instanceof InfantryAttack) - || (m.getType() == armor) || (m.getLocation() == BattleArmor.LOC_NONE)) { + || (m.getType() == armor)) { continue; } + // Skip LOC_NONE equipment except for cockpit modifications like DNI and EI + if (m.getLocation() == BattleArmor.LOC_NONE) { + if (!(m.getType() instanceof MiscType) || + (!m.getType().hasFlag(MiscType.F_DNI_COCKPIT_MOD) && + !m.getType().hasFlag(MiscType.F_EI_INTERFACE))) { + continue; + } + } if ((m.getType() instanceof MiscType) && m.getType().hasFlag(MiscType.F_BA_MANIPULATOR)) { continue; } diff --git a/megamek/src/megamek/common/templates/SupportVeeTROView.java b/megamek/src/megamek/common/templates/SupportVeeTROView.java index fe3110d3090..99c19795bec 100644 --- a/megamek/src/megamek/common/templates/SupportVeeTROView.java +++ b/megamek/src/megamek/common/templates/SupportVeeTROView.java @@ -268,4 +268,15 @@ protected int addEquipment(Entity entity, boolean includeAmmo) { return nameWidth; } + @Override + protected boolean skipMount(Mounted mount, boolean includeAmmo) { + if (mount.getLocation() == Entity.LOC_NONE) { + // Skip armor, structure, and CASE. Show cockpit modifications like DNI. + return mount.getType().hasFlag(MiscType.F_CASE) + || EquipmentType.isArmorType(mount.getType()) + || EquipmentType.isStructureType(mount.getType()); + } + return super.skipMount(mount, includeAmmo); + } + } diff --git a/megamek/src/megamek/common/templates/VehicleTROView.java b/megamek/src/megamek/common/templates/VehicleTROView.java index 98cd1b5d5c5..5abd49c2107 100644 --- a/megamek/src/megamek/common/templates/VehicleTROView.java +++ b/megamek/src/megamek/common/templates/VehicleTROView.java @@ -39,7 +39,9 @@ import java.util.Map; import megamek.common.Messages; +import megamek.common.equipment.EquipmentType; import megamek.common.equipment.GunEmplacement; +import megamek.common.equipment.MiscType; import megamek.common.equipment.Mounted; import megamek.common.equipment.Transporter; import megamek.common.units.Entity; @@ -211,4 +213,15 @@ protected int addEquipment(Entity entity, boolean includeAmmo) { } return retVal; } + + @Override + protected boolean skipMount(Mounted mount, boolean includeAmmo) { + if (mount.getLocation() == Entity.LOC_NONE) { + // Skip armor, structure, and CASE. Show cockpit modifications like DNI. + return mount.getType().hasFlag(MiscType.F_CASE) + || EquipmentType.isArmorType(mount.getType()) + || EquipmentType.isStructureType(mount.getType()); + } + return super.skipMount(mount, includeAmmo); + } } diff --git a/megamek/src/megamek/common/verifier/TestEntity.java b/megamek/src/megamek/common/verifier/TestEntity.java index 54e20cedc07..96ec116482e 100755 --- a/megamek/src/megamek/common/verifier/TestEntity.java +++ b/megamek/src/megamek/common/verifier/TestEntity.java @@ -81,6 +81,15 @@ public abstract class TestEntity implements TestEntityOption { protected Structure structure; private final TestEntityOption options; + /** + * Optional game year to use for intro date validation instead of the unit's intro year. + * When set to a value > 0, {@link #hasIncorrectIntroYear(StringBuffer)} will compare equipment + * intro dates against this year instead of the entity's year. This supports the "Use Game Year" + * setting in MegaMekLab where equipment availability is determined by the configured game year + * rather than the unit's intro year. + */ + private int gameYear = -1; + public abstract Entity getEntity(); public abstract boolean isTank(); @@ -284,6 +293,28 @@ public int getIntroYearMargin() { return options.getIntroYearMargin(); } + /** + * Gets the game year to use for intro date validation. When > 0, equipment intro dates are + * compared against this year instead of the entity's intro year. + * + * @return The game year, or -1 if not set (use entity year) + */ + public int getGameYear() { + return gameYear; + } + + /** + * Sets the game year to use for intro date validation. When set to a value > 0, equipment intro + * dates will be compared against this year instead of the entity's intro year. This supports + * scenarios where equipment availability is determined by a campaign's current year rather than + * the unit's original intro year. + * + * @param gameYear The game year to use, or -1 to use entity year + */ + public void setGameYear(int gameYear) { + this.gameYear = gameYear; + } + @Override public boolean skip() { return !skipBuildValidation() && options.skip(); @@ -1381,7 +1412,14 @@ public boolean hasIllegalTechLevels(StringBuffer buff, int ammoTechLvl) { } /** - * Compares intro dates of all components to the unit intro year. + * Compares intro dates of all components to the unit intro year (or game year if available). + * The year used for comparison is determined in order of priority: + *
    + *
  1. If {@link #setGameYear(int)} was called with a value > 0, use that year
  2. + *
  3. Otherwise, use {@link Entity#getTechLevelYear()} which returns the game's ALLOWED_YEAR + * if the entity is part of a game, or the entity's intro year if not
  4. + *
+ * This supports both MegaMek gameplay (using game options) and MegaMekLab (using config settings). * * @param buff Descriptions of problems will be added to the buffer. * @@ -1389,10 +1427,13 @@ public boolean hasIllegalTechLevels(StringBuffer buff, int ammoTechLvl) { */ public boolean hasIncorrectIntroYear(StringBuffer buff) { boolean retVal = false; - if (getEntity().getEarliestTechDate() <= getEntity().getYear() + getIntroYearMargin()) { + // Use explicitly set game year if available, otherwise use entity's tech level year + // (which checks game options first, then falls back to entity year) + int baseYear = (gameYear > 0) ? gameYear : getEntity().getTechLevelYear(); + if (getEntity().getEarliestTechDate() <= baseYear + getIntroYearMargin()) { return false; } - int useIntroYear = getEntity().getYear() + getIntroYearMargin(); + int useIntroYear = baseYear + getIntroYearMargin(); if (getEntity().isOmni()) { int introDate = Entity.getOmniAdvancement(getEntity()).getIntroductionDate( getEntity().isClan() || getEntity().isMixedTech()); @@ -1409,9 +1450,10 @@ public boolean hasIncorrectIntroYear(StringBuffer buff) { if (checked.contains(nextE) || (nextE instanceof AmmoType)) { continue; } - // Skip EI Interface - it's retrofittable equipment (IO p.69) - // Its intro year should not be compared against unit intro year - if ((nextE instanceof MiscType) && nextE.hasFlag(MiscType.F_EI_INTERFACE)) { + // Skip EI Interface and DNI - they're retrofittable equipment (IO p.69) + // Their intro year should not be compared against unit intro year + if ((nextE instanceof MiscType) && (nextE.hasFlag(MiscType.F_EI_INTERFACE) + || nextE.hasFlag(MiscType.F_DNI_COCKPIT_MOD))) { continue; } checked.add(nextE); diff --git a/megamek/src/megamek/common/verifier/TestMek.java b/megamek/src/megamek/common/verifier/TestMek.java index 5976cb984e6..3b1c347af4d 100755 --- a/megamek/src/megamek/common/verifier/TestMek.java +++ b/megamek/src/megamek/common/verifier/TestMek.java @@ -41,6 +41,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.Vector; import java.util.stream.Collectors; @@ -1087,7 +1088,7 @@ && countCriticalSlotsFromEquipInLocation(mek, m, loc) != 1) { } if (mek.getCockpitType() == Mek.COCKPIT_INTERFACE) { - if (mek.getCockpit().stream().anyMatch(CriticalSlot::isArmored)) { + if (mek.getCockpit().stream().filter(Objects::nonNull).anyMatch(CriticalSlot::isArmored)) { buff.append("Interface cockpits may not use component armoring.\n"); illegal = true; } diff --git a/megamek/unittests/megamek/common/CockpitModificationTest.java b/megamek/unittests/megamek/common/CockpitModificationTest.java new file mode 100644 index 00000000000..14aab59358f --- /dev/null +++ b/megamek/unittests/megamek/common/CockpitModificationTest.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMek. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ +package megamek.common; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import megamek.common.equipment.Engine; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.MiscType; +import megamek.common.loaders.BLKFile; +import megamek.common.loaders.BLKTankFile; +import megamek.common.loaders.MtfFile; +import megamek.common.TechConstants; +import megamek.common.units.BipedMek; +import megamek.common.units.Entity; +import megamek.common.units.EntityMovementMode; +import megamek.common.units.Mek; +import megamek.common.units.Tank; +import megamek.common.util.BuildingBlock; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests for cockpit modification equipment (DNI Cockpit Mod, EI Interface, Damage Interrupt Circuit) + * to verify they are correctly saved and loaded from unit files. + */ +class CockpitModificationTest { + + @BeforeAll + static void beforeAll() { + EquipmentType.initializeTypes(); + } + + // Helper method to convert a Mek to MTF format and reload it + private Mek saveAndReloadMek(Mek mek) throws Exception { + if (!mek.hasEngine() || mek.getEngine().getEngineType() == Engine.NONE) { + mek.setWeight(20.0); + mek.setEngine(new Engine(100, Engine.NORMAL_ENGINE, 0)); + } + String mtf = mek.getMtf(); + byte[] bytes = mtf.getBytes(); + InputStream inputStream = new ByteArrayInputStream(bytes); + MtfFile loader = new MtfFile(inputStream); + return (Mek) loader.getEntity(); + } + + // ========== DNI Cockpit Modification Tests ========== + + @Test + void testMekDNICockpitModSaveAndLoad() throws Exception { + // Create a Mek with DNI Cockpit Modification + Mek mek = new BipedMek(); + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + mek.addEquipment(dniMod, Entity.LOC_NONE); + + // Verify it has the mod before save + assertTrue(mek.hasDNICockpitMod(), "Mek should have DNI Cockpit Mod before save"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still has the mod after load + assertTrue(loadedMek.hasDNICockpitMod(), "Mek should have DNI Cockpit Mod after load"); + } + + @Test + void testMekWithoutDNICockpitMod() throws Exception { + // Create a Mek without DNI Cockpit Modification + Mek mek = new BipedMek(); + + // Verify it doesn't have the mod + assertFalse(mek.hasDNICockpitMod(), "Mek should not have DNI Cockpit Mod"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still doesn't have the mod after load + assertFalse(loadedMek.hasDNICockpitMod(), "Mek should not have DNI Cockpit Mod after load"); + } + + // ========== EI Interface Tests ========== + + @Test + void testMekEIInterfaceSaveAndLoad() throws Exception { + // Create a Mek with EI Interface + Mek mek = new BipedMek(); + MiscType eiInterface = (MiscType) EquipmentType.get("EIInterface"); + mek.addEquipment(eiInterface, Entity.LOC_NONE); + + // Verify it has the interface before save + assertTrue(mek.hasEiCockpit(), "Mek should have EI Interface before save"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still has the interface after load + assertTrue(loadedMek.hasEiCockpit(), "Mek should have EI Interface after load"); + } + + @Test + void testMekWithoutEIInterface() throws Exception { + // Create a Mek without EI Interface + Mek mek = new BipedMek(); + + // Verify it doesn't have the interface + assertFalse(mek.hasEiCockpit(), "Mek should not have EI Interface"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still doesn't have the interface after load + assertFalse(loadedMek.hasEiCockpit(), "Mek should not have EI Interface after load"); + } + + // ========== Damage Interrupt Circuit Tests ========== + + @Test + void testMekDamageInterruptCircuitSaveAndLoad() throws Exception { + // Create a Mek with Damage Interrupt Circuit + Mek mek = new BipedMek(); + MiscType dic = (MiscType) EquipmentType.get("DamageInterruptCircuit"); + mek.addEquipment(dic, Entity.LOC_NONE); + + // Verify it has the circuit before save + assertTrue(mek.hasDamageInterruptCircuit(), "Mek should have Damage Interrupt Circuit before save"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still has the circuit after load + assertTrue(loadedMek.hasDamageInterruptCircuit(), "Mek should have Damage Interrupt Circuit after load"); + } + + @Test + void testMekWithoutDamageInterruptCircuit() throws Exception { + // Create a Mek without Damage Interrupt Circuit + Mek mek = new BipedMek(); + + // Verify it doesn't have the circuit + assertFalse(mek.hasDamageInterruptCircuit(), "Mek should not have Damage Interrupt Circuit"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still doesn't have the circuit after load + assertFalse(loadedMek.hasDamageInterruptCircuit(), "Mek should not have Damage Interrupt Circuit after load"); + } + + // ========== Multiple Modifications Test ========== + + @Test + void testMekMultipleCockpitModsSaveAndLoad() throws Exception { + // Create a Mek with multiple cockpit modifications + Mek mek = new BipedMek(); + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + MiscType dic = (MiscType) EquipmentType.get("DamageInterruptCircuit"); + mek.addEquipment(dniMod, Entity.LOC_NONE); + mek.addEquipment(dic, Entity.LOC_NONE); + + // Verify it has both mods before save + assertTrue(mek.hasDNICockpitMod(), "Mek should have DNI Cockpit Mod before save"); + assertTrue(mek.hasDamageInterruptCircuit(), "Mek should have Damage Interrupt Circuit before save"); + assertFalse(mek.hasEiCockpit(), "Mek should not have EI Interface"); + + // Save and reload + Mek loadedMek = saveAndReloadMek(mek); + + // Verify it still has both mods after load + assertTrue(loadedMek.hasDNICockpitMod(), "Mek should have DNI Cockpit Mod after load"); + assertTrue(loadedMek.hasDamageInterruptCircuit(), "Mek should have Damage Interrupt Circuit after load"); + assertFalse(loadedMek.hasEiCockpit(), "Mek should not have EI Interface after load"); + } + + // ========== Tank BLK Save/Load Tests ========== + + // Helper method to convert a Tank to BLK format and reload it + private Tank saveAndReloadTank(Tank tank) throws Exception { + BuildingBlock blk = BLKFile.getBlock(tank); + BLKTankFile loader = new BLKTankFile(blk); + return (Tank) loader.getEntity(); + } + + @Test + void testTankDNICockpitModSaveAndLoad() throws Exception { + // Create a Tank with DNI Cockpit Modification + Tank tank = new Tank(); + tank.setChassis("Test"); + tank.setModel("Tank"); + tank.setWeight(20.0); + tank.setMovementMode(EntityMovementMode.TRACKED); + tank.setEngine(new Engine(100, Engine.NORMAL_ENGINE, Engine.TANK_ENGINE)); + tank.setOriginalWalkMP(5); + tank.setArmorType(EquipmentType.T_ARMOR_STANDARD); + tank.setArmorTechLevel(TechConstants.T_INTRO_BOX_SET); + tank.autoSetInternal(); + tank.initializeArmor(10, Tank.LOC_FRONT); + tank.initializeArmor(10, Tank.LOC_RIGHT); + tank.initializeArmor(10, Tank.LOC_LEFT); + tank.initializeArmor(10, Tank.LOC_REAR); + + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + tank.addEquipment(dniMod, Entity.LOC_NONE); + + // Verify it has the mod before save + assertTrue(tank.hasDNICockpitMod(), "Tank should have DNI Cockpit Mod before save"); + + // Save and reload + Tank loadedTank = saveAndReloadTank(tank); + + // Verify it still has the mod after load + assertTrue(loadedTank.hasDNICockpitMod(), "Tank should have DNI Cockpit Mod after load"); + } + + @Test + void testTankWithoutDNICockpitMod() throws Exception { + // Create a Tank without DNI Cockpit Modification + Tank tank = new Tank(); + tank.setChassis("Test"); + tank.setModel("Tank"); + tank.setWeight(20.0); + tank.setMovementMode(EntityMovementMode.TRACKED); + tank.setEngine(new Engine(100, Engine.NORMAL_ENGINE, Engine.TANK_ENGINE)); + tank.setOriginalWalkMP(5); + tank.setArmorType(EquipmentType.T_ARMOR_STANDARD); + tank.setArmorTechLevel(TechConstants.T_INTRO_BOX_SET); + tank.autoSetInternal(); + tank.initializeArmor(10, Tank.LOC_FRONT); + tank.initializeArmor(10, Tank.LOC_RIGHT); + tank.initializeArmor(10, Tank.LOC_LEFT); + tank.initializeArmor(10, Tank.LOC_REAR); + + // Verify it doesn't have the mod + assertFalse(tank.hasDNICockpitMod(), "Tank should not have DNI Cockpit Mod"); + + // Save and reload + Tank loadedTank = saveAndReloadTank(tank); + + // Verify it still doesn't have the mod after load + assertFalse(loadedTank.hasDNICockpitMod(), "Tank should not have DNI Cockpit Mod after load"); + } +} diff --git a/megamek/unittests/megamek/common/DamageInterruptCircuitTest.java b/megamek/unittests/megamek/common/DamageInterruptCircuitTest.java index a229551f0b5..bdb54c23bc2 100644 --- a/megamek/unittests/megamek/common/DamageInterruptCircuitTest.java +++ b/megamek/unittests/megamek/common/DamageInterruptCircuitTest.java @@ -385,14 +385,52 @@ void tacOnAlreadyDisabledDic() { class CostCalculationTests { @Test - @DisplayName("DIC base cost should be 150 C-bills") - void dicBaseCost() { + @DisplayName("DIC should use variable cost (150 C-bills per pilot seat)") + void dicUsesVariableCost() { EquipmentType dicType = EquipmentType.get("DamageInterruptCircuit"); assertNotNull(dicType, "DIC equipment should exist"); - // Per IO p.39: Cost is 150 C-bills per pilot seat - // The base cost in MiscType is 150 - assertEquals(150, dicType.getRawCost(), 0.01, "DIC base cost should be 150 C-bills"); + // Per IO:AE p.62: Cost is 150 C-bills per pilot seat + // DIC uses COST_VARIABLE and calculates cost based on crew slots + assertEquals(EquipmentType.COST_VARIABLE, dicType.getRawCost(), 0.01, + "DIC should use COST_VARIABLE for dynamic cost calculation"); + } + + @Test + @DisplayName("DIC cost should be 150 C-bills for single pilot Mek") + void dicCostForSinglePilot() throws Exception { + Mek mek = createMek(true, false); + EquipmentType dicType = EquipmentType.get("DamageInterruptCircuit"); + assertNotNull(dicType, "DIC equipment should exist"); + + // Get the mounted DIC and check its calculated cost + double dicCost = dicType.getCost(mek, false, Entity.LOC_NONE, 1.0); + assertEquals(150, dicCost, 0.01, + "DIC cost should be 150 C-bills for single pilot (150 * 1 seat)"); + } + + @Test + @DisplayName("DIC cost should be 300 C-bills for Command Console Mek") + void dicCostForCommandConsole() { + BipedMek mek = new BipedMek(); + mek.setGame(game); + mek.setId(1); + mek.setChassis("Test Mek"); + mek.setModel("DIC Command Cost"); + mek.setWeight(50); + + // Set up dual crew (Command Console has 2 crew slots) + Crew crew = new Crew(CrewType.COMMAND_CONSOLE); + mek.setCrew(crew); + mek.setOwner(game.getPlayer(0)); + + EquipmentType dicType = EquipmentType.get("DamageInterruptCircuit"); + assertNotNull(dicType, "DIC equipment should exist"); + + // Command Console = 2 crew slots, so DIC cost should be 300 C-bills + double dicCost = dicType.getCost(mek, false, Entity.LOC_NONE, 1.0); + assertEquals(300, dicCost, 0.01, + "DIC cost should be 300 C-bills for Command Console (150 * 2 seats)"); } @Test diff --git a/megamek/unittests/megamek/common/verifier/TestEntityGameYearValidationTest.java b/megamek/unittests/megamek/common/verifier/TestEntityGameYearValidationTest.java new file mode 100644 index 00000000000..4e4de3942ef --- /dev/null +++ b/megamek/unittests/megamek/common/verifier/TestEntityGameYearValidationTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * + * This file is part of MegaMek. + * + * MegaMek is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MegaMek is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ +package megamek.common.verifier; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import megamek.common.TechConstants; +import megamek.common.equipment.Engine; +import megamek.common.equipment.EquipmentType; +import megamek.common.equipment.MiscType; +import megamek.common.units.BipedMek; +import megamek.common.units.Entity; +import megamek.common.units.Mek; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Tests for TestEntity game year validation functionality. + * Verifies that hasIncorrectIntroYear() correctly uses the game year setting + * when validating equipment intro dates, and that retrofittable equipment + * (DNI, EI) is skipped during validation. + */ +class TestEntityGameYearValidationTest { + + @BeforeAll + static void beforeAll() { + EquipmentType.initializeTypes(); + } + + /** + * Creates a basic Mek for testing with the specified intro year. + */ + private Mek createTestMek(int introYear) { + Mek mek = new BipedMek(); + mek.setChassis("Test"); + mek.setModel("Mek"); + mek.setYear(introYear); + mek.setWeight(20.0); + mek.setEngine(new Engine(100, Engine.NORMAL_ENGINE, 0)); + return mek; + } + + /** + * Creates a TestMek verifier for the given Mek. + */ + private TestMek createTestEntity(Mek mek) { + EntityVerifier verifier = EntityVerifier.getInstance( + new java.io.File("testresources/data/mekfiles/UnitVerifierOptions.xml")); + return new TestMek(mek, verifier.mekOption, null); + } + + // ========== Game Year Setter/Getter Tests ========== + + @Test + void testGameYearDefaultsToNegativeOne() { + Mek mek = createTestMek(3025); + TestMek testEntity = createTestEntity(mek); + + assertEquals(-1, testEntity.getGameYear(), "Game year should default to -1"); + } + + @Test + void testSetGameYear() { + Mek mek = createTestMek(3025); + TestMek testEntity = createTestEntity(mek); + + testEntity.setGameYear(3050); + assertEquals(3050, testEntity.getGameYear(), "Game year should be set to 3050"); + } + + // ========== DNI Retrofittable Equipment Tests ========== + + @Test + void testDNISkippedAsRetrofittableEquipment() throws Exception { + // Create a Mek with intro year well before DNI (3052) + // DNI should be skipped during validation because it's retrofittable + Mek mek = createTestMek(2500); + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + mek.addEquipment(dniMod, Entity.LOC_NONE); + + TestMek testEntity = createTestEntity(mek); + // Don't set game year - use entity year (2500) + // DNI intro is 3052, but it should be skipped as retrofittable + + StringBuffer buff = new StringBuffer(); + boolean hasIncorrectIntro = testEntity.hasIncorrectIntroYear(buff); + + // DNI is retrofittable equipment, so it should be skipped in validation + assertFalse(hasIncorrectIntro, + "DNI should not cause validation failure (retrofittable equipment): " + buff); + } + + @Test + void testDNISkippedEvenWithEarlyGameYear() throws Exception { + // Create a Mek with intro year before DNI (3052) + Mek mek = createTestMek(2500); + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + mek.addEquipment(dniMod, Entity.LOC_NONE); + + TestMek testEntity = createTestEntity(mek); + // Set game year BEFORE DNI intro - but DNI should still pass because it's retrofittable + testEntity.setGameYear(3000); + + StringBuffer buff = new StringBuffer(); + boolean hasIncorrectIntro = testEntity.hasIncorrectIntroYear(buff); + + // DNI is retrofittable equipment, so it should be skipped in validation + assertFalse(hasIncorrectIntro, + "DNI should not cause validation failure even with early game year (retrofittable): " + buff); + } + + // ========== EI Interface Retrofittable Equipment Tests ========== + + @Test + void testEISkippedAsRetrofittableEquipment() throws Exception { + // Create a Clan Mek with intro year well before EI (3040) + Mek mek = createTestMek(2800); + mek.setTechLevel(TechConstants.T_CLAN_TW); // EI is Clan only + MiscType eiInterface = (MiscType) EquipmentType.get("EIInterface"); + mek.addEquipment(eiInterface, Entity.LOC_NONE); + + TestMek testEntity = createTestEntity(mek); + // Don't set game year - use entity year (2800) + // EI intro is 3040, but it should be skipped as retrofittable + + StringBuffer buff = new StringBuffer(); + boolean hasIncorrectIntro = testEntity.hasIncorrectIntroYear(buff); + + // EI is retrofittable equipment, so it should be skipped in validation + assertFalse(hasIncorrectIntro, + "EI should not cause validation failure (retrofittable equipment): " + buff); + } + + @Test + void testEISkippedEvenWithEarlyGameYear() throws Exception { + // Create a Clan Mek with intro year before EI (3040) + Mek mek = createTestMek(2800); + mek.setTechLevel(TechConstants.T_CLAN_TW); // EI is Clan only + MiscType eiInterface = (MiscType) EquipmentType.get("EIInterface"); + mek.addEquipment(eiInterface, Entity.LOC_NONE); + + TestMek testEntity = createTestEntity(mek); + // Set game year BEFORE EI intro - but EI should still pass because it's retrofittable + testEntity.setGameYear(3000); + + StringBuffer buff = new StringBuffer(); + boolean hasIncorrectIntro = testEntity.hasIncorrectIntroYear(buff); + + // EI is retrofittable equipment, so it should be skipped in validation + assertFalse(hasIncorrectIntro, + "EI should not cause validation failure even with early game year (retrofittable): " + buff); + } + + // ========== Game Year Field Tests ========== + + @Test + void testGameYearFieldIsUsedWhenSet() { + // Verify that when setGameYear is called, the value is stored and retrievable + Mek mek = createTestMek(3025); + TestMek testEntity = createTestEntity(mek); + + // Initially should be -1 + assertEquals(-1, testEntity.getGameYear()); + + // Set to a specific year + testEntity.setGameYear(3145); + assertEquals(3145, testEntity.getGameYear()); + + // Can be reset + testEntity.setGameYear(3050); + assertEquals(3050, testEntity.getGameYear()); + + // Can be reset to -1 (use entity year) + testEntity.setGameYear(-1); + assertEquals(-1, testEntity.getGameYear()); + } + + @Test + void testMultipleCockpitModsAllSkipped() throws Exception { + // Create a Mek with both DNI and EI (theoretically) + // Both should be skipped as retrofittable + Mek mek = createTestMek(2500); + mek.setTechLevel(TechConstants.T_CLAN_TW); + MiscType dniMod = (MiscType) EquipmentType.get("DNICockpitModification"); + MiscType eiInterface = (MiscType) EquipmentType.get("EIInterface"); + mek.addEquipment(dniMod, Entity.LOC_NONE); + mek.addEquipment(eiInterface, Entity.LOC_NONE); + + TestMek testEntity = createTestEntity(mek); + testEntity.setGameYear(3000); // Before both DNI (3052) and EI (3040) intros + + StringBuffer buff = new StringBuffer(); + boolean hasIncorrectIntro = testEntity.hasIncorrectIntroYear(buff); + + // Both should be skipped as retrofittable + assertFalse(hasIncorrectIntro, + "Both DNI and EI should be skipped as retrofittable: " + buff); + } +}