diff --git a/MekHQ/resources/mekhq/resources/CampaignOptionsDialog.properties b/MekHQ/resources/mekhq/resources/CampaignOptionsDialog.properties index 7d261396bd4..c60aad44d96 100644 --- a/MekHQ/resources/mekhq/resources/CampaignOptionsDialog.properties +++ b/MekHQ/resources/mekhq/resources/CampaignOptionsDialog.properties @@ -1281,6 +1281,8 @@ lblPayForHousingBox.tooltip=Each month, while not in transit, funds are deducted # createGeneralOptionsPanel lblUseLoanLimitsBox.text=Available Loans Based on Unit Reputation \u270E lblUseLoanLimitsBox.tooltip=Put limits on interest, collateral, and length. +lblTrackLeasesBox.text=Leases Available for Large Craft +lblTrackLeasesBox.tooltip=Allow leases to be obtained for Dropships and Jumpships instead of purchasing outright lblUsePercentageMaintenanceBox.text=Enable Percentage-Based Maintenance Costs \u270E lblUsePercentageMaintenanceBox.tooltip=Maintenance costs based upon the value of the unit instead\ \ of the unit type. This makes maintenance costs more impactful. diff --git a/MekHQ/resources/mekhq/resources/Finances.properties b/MekHQ/resources/mekhq/resources/Finances.properties index 9ca827c9283..1ca3a2cbf34 100644 --- a/MekHQ/resources/mekhq/resources/Finances.properties +++ b/MekHQ/resources/mekhq/resources/Finances.properties @@ -43,6 +43,8 @@ TransactionType.FINANCIAL_TERM_END_CARRYOVER.text=Financial Term End Carryover TransactionType.FINANCIAL_TERM_END_CARRYOVER.toolTipText=Funds carried over from the previous financial term. TransactionType.FINE.text=Fine TransactionType.FINE.toolTipText=A financial transaction where the force pays or is paid a fine. +TransactionType.LEASE_PAYMENT.text=Lease Payment +TransactionType.LEASE_PAYMENT.toolTipText=A financial transaction where a lease was being paid for. TransactionType.LOAN_PAYMENT.text=Loan Payment TransactionType.LOAN_PAYMENT.toolTipText=A financial transaction where a loan was being paid for. TransactionType.LOAN_PRINCIPAL.text=Loan Principal @@ -83,6 +85,8 @@ TransactionType.UNIT_PURCHASE.text=Unit Purchase(s) TransactionType.UNIT_PURCHASE.toolTipText=A financial transaction where a unit was or multiple units were purchased. TransactionType.UNIT_SALE.text=Unit Sale(s) TransactionType.UNIT_SALE.toolTipText=A financial transaction where a unit was or multiple units were sold. +TransactionType.UNIT_CANCEL_LEASE.text=Cancel Unit Lease +TransactionType.UNIT_CANCEL_LEASE.toolTipText=A financial transaction where a unit's lease was finalized. TransactionType.BONUS_EXCHANGE.text=Bonus Exchange TransactionType.BONUS_EXCHANGE.toolTipText=A financial transaction where Bonus Parts were exchanged for money. TransactionType.WEALTH.text=Reinvestment @@ -109,6 +113,10 @@ Loan.title=loan payment to %s Loan.text=Your account has been debited for %s in loan payment to %s Loan.insufficient.report=You have insufficient funds to service the debt on loan %s!%s Funds required: %s Loan.paid.report=You have fully paid off loan %s +# Leases +LeaseCosts.title=Lease payments +LeaseCosts.text=Your account has been debited for %s in lease payments. +LeaseCosts.insufficient.report=You have insufficient funds to service your leases! Funds Required: %s # File Export FinanceExport.format=%s financial transactions written to file. ## Unsorted General Finances diff --git a/MekHQ/resources/mekhq/resources/UnitViewPanel.properties b/MekHQ/resources/mekhq/resources/UnitViewPanel.properties index 3b0b6ca4dde..437cffa8d5a 100644 --- a/MekHQ/resources/mekhq/resources/UnitViewPanel.properties +++ b/MekHQ/resources/mekhq/resources/UnitViewPanel.properties @@ -7,3 +7,4 @@ lblTonnage1.text=Tonnage: lblBV1.text=BV: lblCost1.text=Cost: lblQuirk1.text=Quirks: +lblLease.text=(Monthly Lease) diff --git a/MekHQ/src/mekhq/campaign/Campaign.java b/MekHQ/src/mekhq/campaign/Campaign.java index 7a24e1941fb..202a9adee82 100644 --- a/MekHQ/src/mekhq/campaign/Campaign.java +++ b/MekHQ/src/mekhq/campaign/Campaign.java @@ -41,7 +41,6 @@ import static mekhq.campaign.CampaignOptions.S_TECH; import static mekhq.campaign.CampaignOptions.TRANSIT_UNIT_MONTH; import static mekhq.campaign.CampaignOptions.TRANSIT_UNIT_WEEK; -import static mekhq.campaign.force.CombatTeam.getStandardForceSize; import static mekhq.campaign.force.CombatTeam.recalculateCombatTeams; import static mekhq.campaign.force.Force.FORCE_NONE; import static mekhq.campaign.force.Force.FORCE_ORIGIN; @@ -1795,6 +1794,10 @@ public Unit addNewUnit(Entity en, boolean allowNewPilots, int days) { * @throws IllegalArgumentException If the quality is not within the valid range (0-5) */ public Unit addNewUnit(Entity en, boolean allowNewPilots, int days, PartQuality quality) { + return addNewUnit(en, allowNewPilots, days, quality, true); + } + + public Unit addNewUnit(Entity en, boolean allowNewPilots, int days, PartQuality quality, boolean startMothballed) { Unit unit = new Unit(en, this); unit.setMaintenanceMultiplier(getCampaignOptions().getDefaultMaintenanceTime()); getHangar().addUnit(unit); @@ -1815,7 +1818,7 @@ public Unit addNewUnit(Entity en, boolean allowNewPilots, int days, PartQuality unit.setDaysToArrival(days); - if (days > 0) { + if (days > 0 && startMothballed) { unit.setMothballed(campaignOptions.isMothballUnitMarketDeliveries()); } @@ -2167,8 +2170,7 @@ public boolean recruitPerson(Person person, PrisonerStatus prisonerStatus, boole personnel.put(person.getId(), person); if (getCampaignOptions().isUseSimulatedRelationships()) { - if ((prisonerStatus.isFree()) && - (!person.getOriginFaction().isClan()) && + if ((prisonerStatus.isFree()) && (!person.getOriginFaction().isClan()) && // We don't simulate for civilians, otherwise MekHQ will try to simulate the entire // relationship history of everyone the recruit has ever married or birthed. This will // cause a StackOverflow. -- Illiani, May/21/2025 @@ -7696,29 +7698,29 @@ public TargetRoll getTargetForAcquisition(final IAcquisitionWork acquisition, fi } public PlanetaryConditions getCurrentPlanetaryConditions(Scenario scenario) { - PlanetaryConditions planetaryConditions = new PlanetaryConditions(); - if (scenario instanceof AtBScenario atBScenario) { - if (getCampaignOptions().isUseLightConditions()) { - planetaryConditions.setLight(atBScenario.getLight()); - } - if (getCampaignOptions().isUseWeatherConditions()) { - planetaryConditions.setWeather(atBScenario.getWeather()); - planetaryConditions.setWind(atBScenario.getWind()); - planetaryConditions.setFog(atBScenario.getFog()); - planetaryConditions.setEMI(atBScenario.getEMI()); - planetaryConditions.setBlowingSand(atBScenario.getBlowingSand()); - planetaryConditions.setTemperature(atBScenario.getModifiedTemperature()); + PlanetaryConditions planetaryConditions = new PlanetaryConditions(); + if (scenario instanceof AtBScenario atBScenario) { + if (getCampaignOptions().isUseLightConditions()) { + planetaryConditions.setLight(atBScenario.getLight()); + } + if (getCampaignOptions().isUseWeatherConditions()) { + planetaryConditions.setWeather(atBScenario.getWeather()); + planetaryConditions.setWind(atBScenario.getWind()); + planetaryConditions.setFog(atBScenario.getFog()); + planetaryConditions.setEMI(atBScenario.getEMI()); + planetaryConditions.setBlowingSand(atBScenario.getBlowingSand()); + planetaryConditions.setTemperature(atBScenario.getModifiedTemperature()); - } - if (getCampaignOptions().isUsePlanetaryConditions()) { - planetaryConditions.setAtmosphere(atBScenario.getAtmosphere()); - planetaryConditions.setGravity(atBScenario.getGravity()); - } - } else { - planetaryConditions = scenario.createPlanetaryConditions(); } + if (getCampaignOptions().isUsePlanetaryConditions()) { + planetaryConditions.setAtmosphere(atBScenario.getAtmosphere()); + planetaryConditions.setGravity(atBScenario.getGravity()); + } + } else { + planetaryConditions = scenario.createPlanetaryConditions(); + } - return planetaryConditions; + return planetaryConditions; } @@ -8851,6 +8853,7 @@ public int calculatePartTransitTime(int availability) { return Math.toIntExact(ChronoUnit.DAYS.between(getLocalDate(), arrivalDate)); } + /** * Calculates the transit time for the arrival of parts or supplies based on the availability of the item, a random * roll, and campaign-specific transit time settings. @@ -9957,8 +9960,7 @@ public Planet getNewCampaignStartingPlanet() { if (faction.getShortName().equalsIgnoreCase("PIR")) { List pirateFactions = new ArrayList<>(); for (Faction activeFaction : factions.getActiveFactions(currentDay)) { - if (activeFaction.isPirate() && - !activeFaction.getShortName().equalsIgnoreCase("PIR")) { + if (activeFaction.isPirate() && !activeFaction.getShortName().equalsIgnoreCase("PIR")) { pirateFactions.add(activeFaction); } } diff --git a/MekHQ/src/mekhq/campaign/CampaignOptions.java b/MekHQ/src/mekhq/campaign/CampaignOptions.java index f515388fc4a..fb5e517a772 100644 --- a/MekHQ/src/mekhq/campaign/CampaignOptions.java +++ b/MekHQ/src/mekhq/campaign/CampaignOptions.java @@ -468,6 +468,7 @@ public static String getTechLevelName(final int techLevel) { private boolean payForOverhead; private boolean payForMaintain; private boolean payForTransport; + private boolean trackLeases; private boolean sellUnits; private boolean sellParts; private boolean payForRecruitment; @@ -1088,6 +1089,7 @@ public CampaignOptions() { payForOverhead = false; payForMaintain = false; payForTransport = false; + trackLeases = false; sellUnits = false; sellParts = false; payForRecruitment = false; @@ -3294,6 +3296,14 @@ public void setPayForTransport(final boolean payForTransport) { this.payForTransport = payForTransport; } + public boolean isTrackLeases() { + return trackLeases; + } + + public void setTrackLeases(final boolean trackLeases) { + this.trackLeases = trackLeases; + } + public boolean isSellUnits() { return sellUnits; } @@ -5317,6 +5327,7 @@ public void writeToXml(final PrintWriter pw, int indent) { MHQXMLUtility.writeSimpleXMLTag(pw, indent, "payForOverhead", payForOverhead); MHQXMLUtility.writeSimpleXMLTag(pw, indent, "payForMaintain", payForMaintain); MHQXMLUtility.writeSimpleXMLTag(pw, indent, "payForTransport", payForTransport); + MHQXMLUtility.writeSimpleXMLTag(pw, indent, "trackLeases", trackLeases); MHQXMLUtility.writeSimpleXMLTag(pw, indent, "sellUnits", sellUnits); MHQXMLUtility.writeSimpleXMLTag(pw, indent, "sellParts", sellParts); MHQXMLUtility.writeSimpleXMLTag(pw, indent, "payForRecruitment", payForRecruitment); @@ -6313,6 +6324,8 @@ public static CampaignOptions generateCampaignOptionsFromXml(Node parentNod, Ver campaignOptions.payForMaintain = Boolean.parseBoolean(nodeContents); } else if (nodeName.equalsIgnoreCase("payForTransport")) { campaignOptions.payForTransport = Boolean.parseBoolean(nodeContents); + } else if (nodeName.equalsIgnoreCase("trackLeases")) { + campaignOptions.trackLeases = Boolean.parseBoolean(nodeContents); } else if (nodeName.equalsIgnoreCase("sellUnits")) { campaignOptions.sellUnits = Boolean.parseBoolean(nodeContents); } else if (nodeName.equalsIgnoreCase("sellParts")) { @@ -6926,19 +6939,19 @@ public boolean isTrackFactionStanding() { public void setTrackFactionStanding(boolean trackFactionStanding) { this.trackFactionStanding = trackFactionStanding; } - + public boolean isAutoGenerateOpForCallsigns() { return autoGenerateOpForCallsigns; } - + public void setAutoGenerateOpForCallsigns(boolean autoGenerateOpForCallsigns) { this.autoGenerateOpForCallsigns = autoGenerateOpForCallsigns; } - + public SkillLevel getMinimumCallsignSkillLevel() { return minimumCallsignSkillLevel; } - + public void setMinimumCallsignSkillLevel(SkillLevel skillLevel) { this.minimumCallsignSkillLevel = skillLevel; } diff --git a/MekHQ/src/mekhq/campaign/Quartermaster.java b/MekHQ/src/mekhq/campaign/Quartermaster.java index 429d7116e23..cc833c4516e 100644 --- a/MekHQ/src/mekhq/campaign/Quartermaster.java +++ b/MekHQ/src/mekhq/campaign/Quartermaster.java @@ -32,6 +32,7 @@ */ package mekhq.campaign; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -40,9 +41,11 @@ import megamek.common.Entity; import megamek.common.annotations.Nullable; import megamek.common.weapons.infantry.InfantryWeapon; +import megamek.logging.MMLogger; import mekhq.MekHQ; import mekhq.campaign.event.PartArrivedEvent; import mekhq.campaign.event.PartChangedEvent; +import mekhq.campaign.finances.Lease; import mekhq.campaign.finances.Money; import mekhq.campaign.finances.enums.TransactionType; import mekhq.campaign.parts.AmmoStorage; @@ -61,16 +64,17 @@ * Manages machines and materiel for a campaign. */ public class Quartermaster { + private static final MMLogger LOGGER = MMLogger.create(Quartermaster.class); + public enum PartAcquisitionResult { - PartInherentFailure, - PlanetSpecificFailure, - Success + PartInherentFailure, PlanetSpecificFailure, Success } private final Campaign campaign; /** * Initializes a new instance of the Quartermaster class. + * * @param campaign The campaign being managed by the Quartermaster. */ public Quartermaster(Campaign campaign) { @@ -99,17 +103,18 @@ protected Warehouse getWarehouse() { } /** - * Adds a part to the campaign, arriving in a set number of days. By default, the part is treated - * as not brand new. This method is deprecated in favor of the overloaded method that explicitly - * accepts a flag to indicate whether the part is brand new. + * Adds a part to the campaign, arriving in a set number of days. By default, the part is treated as not brand new. + * This method is deprecated in favor of the overloaded method that explicitly accepts a flag to indicate whether + * the part is brand new. * *

This method delegates its behavior to {@link #addPart(Part, int, boolean)} with the * {@code isBrandNew} flag set to {@code false}.

* * @param part The part to add to the campaign. Cannot be {@code null}. * @param transitDays The number of days until the part arrives, or zero if the part is already here. - * @deprecated Use {@link #addPart(Part, int, boolean)} instead to explicitly indicate whether - * the part is brand new. + * + * @deprecated Use {@link #addPart(Part, int, boolean)} instead to explicitly indicate whether the part is brand + * new. */ @Deprecated public void addPart(Part part, int transitDays) { @@ -117,19 +122,19 @@ public void addPart(Part part, int transitDays) { } /** - * Adds a part to the campaign's warehouse, specifying the number of transit days for its arrival - * and whether the part is considered brand new. The method validates the input and decides whether - * to skip the addition based on specific conditions, such as test units, spare ammo bins, or - * missing parts without associated units. + * Adds a part to the campaign's warehouse, specifying the number of transit days for its arrival and whether the + * part is considered brand new. The method validates the input and decides whether to skip the addition based on + * specific conditions, such as test units, spare ammo bins, or missing parts without associated units. * *

Once validated, the part is marked as new or used, set to arrive in the specified number of * days, processed for campaign addition, and added to the campaign's warehouse.

* * @param part The part to add to the campaign. Cannot be {@code null}. - * @param transitDays The number of days until the part arrives. If the value is negative, - * it will be adjusted to zero, indicating the part is already here. - * @param isBrandNew A {@code boolean} indicating whether the part is brand new. - * {@code true} if the part is new, otherwise {@code false}. + * @param transitDays The number of days until the part arrives. If the value is negative, it will be adjusted to + * zero, indicating the part is already here. + * @param isBrandNew A {@code boolean} indicating whether the part is brand new. {@code true} if the part is new, + * otherwise {@code false}. + * * @throws NullPointerException If {@code part} is {@code null}. */ public void addPart(Part part, int transitDays, boolean isBrandNew) { @@ -169,8 +174,9 @@ public void addPart(Part part, int transitDays, boolean isBrandNew) { /** * Adds ammo to the campaign. + * * @param ammoType The type of ammo to add. - * @param shots The number of rounds of ammo to add. + * @param shots The number of rounds of ammo to add. */ public void addAmmo(AmmoType ammoType, int shots) { Objects.requireNonNull(ammoType); @@ -182,9 +188,10 @@ public void addAmmo(AmmoType ammoType, int shots) { /** * Adds infantry ammo to the campaign. - * @param ammoType The type of ammo to add. + * + * @param ammoType The type of ammo to add. * @param infantryWeapon The type of infantry weapon using the ammo. - * @param shots The number of rounds of ammo to add. + * @param shots The number of rounds of ammo to add. */ public void addAmmo(AmmoType ammoType, InfantryWeapon infantryWeapon, int shots) { Objects.requireNonNull(ammoType); @@ -197,10 +204,12 @@ public void addAmmo(AmmoType ammoType, InfantryWeapon infantryWeapon, int shots) /** * Removes ammo from the campaign, if available. - * @param ammoType The type of ammo to remove. + * + * @param ammoType The type of ammo to remove. * @param shotsNeeded The number of rounds of ammo needed. - * @return The number of rounds of ammo removed from the campaign. - * This value may be less than or equal to {@code shotsNeeded}. + * + * @return The number of rounds of ammo removed from the campaign. This value may be less than or equal to + * {@code shotsNeeded}. */ public int removeAmmo(AmmoType ammoType, int shotsNeeded) { Objects.requireNonNull(ammoType); @@ -227,7 +236,9 @@ public int removeAmmo(AmmoType ammoType, int shotsNeeded) { /** * Remove ammo directly from an AmmoStorage part. + * * @param shotsNeeded The number of shots needed. + * * @return The number of shots removed. */ private int removeAmmo(@Nullable AmmoStorage ammoStorage, int shotsNeeded) { @@ -254,10 +265,12 @@ private int removeAmmo(@Nullable AmmoStorage ammoStorage, int shotsNeeded) { /** * Removes compatible ammo from the campaign, if available. - * @param ammoType The type of ammo to remove. + * + * @param ammoType The type of ammo to remove. * @param shotsNeeded The number of rounds of ammo needed. - * @return The number of rounds of ammo removed from the campaign. - * This value may be less than or equal to {@code shotsNeeded}. + * + * @return The number of rounds of ammo removed from the campaign. This value may be less than or equal to + * {@code shotsNeeded}. */ public int removeCompatibleAmmo(AmmoType ammoType, int shotsNeeded) { Objects.requireNonNull(ammoType); @@ -304,19 +317,22 @@ public int removeCompatibleAmmo(AmmoType ammoType, int shotsNeeded) { /** * Finds spare ammo of a given type, if any. + * * @param ammoType The AmmoType to search for. + * * @return The matching spare {@code AmmoStorage} part, otherwise {@code null}. */ private @Nullable AmmoStorage findSpareAmmo(AmmoType ammoType) { return (AmmoStorage) getWarehouse().findSparePart(part -> { - return isAvailableAsSpareAmmo(part) - && ((AmmoStorage) part).isSameAmmoType(ammoType); + return isAvailableAsSpareAmmo(part) && ((AmmoStorage) part).isSameAmmoType(ammoType); }); } /** * Find compatible ammo in the warehouse. + * * @param ammoType The AmmoType to search for compatible types. + * * @return A list of spare {@code AmmoStorage} parts in the warehouse. */ private List findCompatibleSpareAmmo(AmmoType ammoType) { @@ -343,11 +359,13 @@ private List findCompatibleSpareAmmo(AmmoType ammoType) { } /** - * Converts shots from one ammo type to another. - * NB: it is up to the caller to ensure the ammo types are compatible. - * @param from The AmmoType for which {@code shots} represents. + * Converts shots from one ammo type to another. NB: it is up to the caller to ensure the ammo types are + * compatible. + * + * @param from The AmmoType for which {@code shots} represents. * @param shots The number of shots of {@code from}. - * @param to The AmmoType which {@code shots} should be converted to. + * @param to The AmmoType which {@code shots} should be converted to. + * * @return The value of {@code shots} when converted to a specific AmmoType. */ public static int convertShots(AmmoType from, int shots, AmmoType to) { @@ -367,11 +385,13 @@ public static int convertShots(AmmoType from, int shots, AmmoType to) { } /** - * Calculates the shots needed when converting from a source ammo to a target ammo. - * NB: it is up to the caller to ensure the ammo types are compatible. - * @param target The target ammo type. + * Calculates the shots needed when converting from a source ammo to a target ammo. NB: it is up to the caller to + * ensure the ammo types are compatible. + * + * @param target The target ammo type. * @param shotsNeeded The number of shots needed in the target ammo type. - * @param source The source ammo type. + * @param source The source ammo type. + * * @return The number of shots needed from the source ammo type. */ public static int convertShotsNeeded(AmmoType target, int shotsNeeded, AmmoType source) { @@ -394,7 +414,9 @@ public static int convertShotsNeeded(AmmoType target, int shotsNeeded, AmmoType /** * Gets the amount of ammo available of a given type. + * * @param ammoType The type of ammo. + * * @return The number of shots available of the given ammo type. */ public int getAmmoAvailable(AmmoType ammoType) { @@ -405,51 +427,43 @@ public int getAmmoAvailable(AmmoType ammoType) { // matching ammo. There may be multiple instances of matching // ammo that have different qualities, so we should return // all of those counts as viable and not just the first we find. - return getWarehouse() - .streamSpareParts() - .filter(Quartermaster::isAvailableAsSpareAmmo) - .mapToInt(part -> { - AmmoStorage spare = (AmmoStorage) part; - if (spare.isSameAmmoType(ammoType)) { - return spare.getShots(); - } - return 0; - }) - .sum(); + return getWarehouse().streamSpareParts().filter(Quartermaster::isAvailableAsSpareAmmo).mapToInt(part -> { + AmmoStorage spare = (AmmoStorage) part; + if (spare.isSameAmmoType(ammoType)) { + return spare.getShots(); + } + return 0; + }).sum(); } else { // If we're using ammo by type, stream through all of // the ammo that matches strictly or is compatible. - return getWarehouse() - .streamSpareParts() - .filter(Quartermaster::isAvailableAsSpareAmmo) - .mapToInt(part -> { - AmmoStorage spare = (AmmoStorage) part; - if (spare.isSameAmmoType(ammoType)) { - return spare.getShots(); - } else if (spare.isCompatibleAmmo(ammoType)) { - return convertShots(spare.getType(), spare.getShots(), ammoType); - } - return 0; - }) - .sum(); + return getWarehouse().streamSpareParts().filter(Quartermaster::isAvailableAsSpareAmmo).mapToInt(part -> { + AmmoStorage spare = (AmmoStorage) part; + if (spare.isSameAmmoType(ammoType)) { + return spare.getShots(); + } else if (spare.isCompatibleAmmo(ammoType)) { + return convertShots(spare.getType(), spare.getShots(), ammoType); + } + return 0; + }).sum(); } } /** - * Gets a value indicating whether or not a given {@code Part} - * is available for use as spare ammo. + * Gets a value indicating whether or not a given {@code Part} is available for use as spare ammo. + * * @param part The part to check if it can be used as spare ammo. */ private static boolean isAvailableAsSpareAmmo(@Nullable Part part) { - return (part instanceof AmmoStorage) - && part.isPresent() - && !part.isReservedForRefit(); + return (part instanceof AmmoStorage) && part.isPresent() && !part.isReservedForRefit(); } /** * Gets the amount of ammo available of a given type. + * * @param ammoType The type of ammo. + * * @return The number of shots available of the given ammo type. */ public int getAmmoAvailable(AmmoType ammoType, InfantryWeapon weaponType) { @@ -461,8 +475,10 @@ public int getAmmoAvailable(AmmoType ammoType, InfantryWeapon weaponType) { /** * Finds spare infantry ammo of a given type, if any. - * @param ammoType The {@code AmmoType} to search for. + * + * @param ammoType The {@code AmmoType} to search for. * @param weaponType The {@code InfantryWeapon} which carries the ammo. + * * @return The matching spare {@code InfantryAmmoStorage} part, otherwise {@code null}. */ private @Nullable InfantryAmmoStorage findSpareAmmo(AmmoType ammoType, InfantryWeapon weaponType) { @@ -476,11 +492,13 @@ public int getAmmoAvailable(AmmoType ammoType, InfantryWeapon weaponType) { /** * Removes infantry ammo from the campaign, if available. - * @param ammoType The type of ammo to remove. + * + * @param ammoType The type of ammo to remove. * @param infantryWeapon The infantry weapon using the ammo. - * @param shotsNeeded The number of rounds of ammo needed. - * @return The number of rounds of ammo removed from the campaign. - * This value may be less than or equal to {@code shotsNeeded}. + * @param shotsNeeded The number of rounds of ammo needed. + * + * @return The number of rounds of ammo removed from the campaign. This value may be less than or equal to + * {@code shotsNeeded}. */ public int removeAmmo(AmmoType ammoType, InfantryWeapon infantryWeapon, int shotsNeeded) { Objects.requireNonNull(ammoType); @@ -498,8 +516,7 @@ public int removeAmmo(AmmoType ammoType, InfantryWeapon infantryWeapon, int shot } /** - * Denotes that a part in-transit has arrived. - * Should be called when a part goes from 1 daysToArrival to zero. + * Denotes that a part in-transit has arrived. Should be called when a part goes from 1 daysToArrival to zero. * * @param part The part which has arrived. */ @@ -527,8 +544,10 @@ public void arrivePart(Part part) { /** * Tries to buy a unit. - * @param en The entity which represents the unit. + * + * @param en The entity which represents the unit. * @param days The number of days until the new unit arrives. + * * @return True if the unit was purchased, otherwise false. */ public boolean buyUnit(Entity en, int days) { @@ -542,8 +561,11 @@ public boolean buyUnit(Entity en, int days) { if (getCampaignOptions().isPayForUnits()) { Money cost = new Unit(en, getCampaign()).getBuyCost(); - if (getCampaign().getFinances().debit(TransactionType.UNIT_PURCHASE, getCampaign().getLocalDate(), - cost, "Purchased " + en.getShortName())) { + if (getCampaign().getFinances() + .debit(TransactionType.UNIT_PURCHASE, + getCampaign().getLocalDate(), + cost, + "Purchased " + en.getShortName())) { getCampaign().addNewUnit(en, false, days, quality); @@ -558,8 +580,36 @@ public boolean buyUnit(Entity en, int days) { } } + /** + * Leases a Unit by creating a new Unit from the entity specified and adding it to the hangar with attached lease. + * + * @param leaseEntity The unit to lease + * @param days The number of days before the lease starts (typically days to arrival) + */ + public boolean createLeasedUnit(Entity leaseEntity, int days) { + if (leaseEntity == null) { + LOGGER.debug("null unit passed into createLeasedUnit - no unit created"); + return false; + } + if (!campaign.getCampaignOptions().isTrackLeases()) { + LOGGER.debug("attempted to createLeasedUnit where isTrackLeases is disabled - no unit created"); + return false; + } + PartQuality quality = PartQuality.QUALITY_D; + + if (campaign.getCampaignOptions().isUseRandomUnitQualities()) { + quality = Unit.getRandomUnitQuality(0); + } + //We don't want to start the new lease until the unit arrives. + LocalDate leaseStart = campaign.getLocalDate().plusDays(days); + Unit newUnit = getCampaign().addNewUnit(leaseEntity, false, days, quality, false); + newUnit.addLease(new Lease(leaseStart, newUnit)); + return true; + } + /** * Sells a unit. + * * @param unit The unit to sell. */ public void sellUnit(Unit unit) { @@ -567,14 +617,42 @@ public void sellUnit(Unit unit) { Money sellValue = unit.getSellValue(); - getCampaign().getFinances().credit(TransactionType.UNIT_SALE, getCampaign().getLocalDate(), - sellValue, "Sale of " + unit.getName()); + getCampaign().getFinances() + .credit(TransactionType.UNIT_SALE, getCampaign().getLocalDate(), sellValue, "Sale of " + unit.getName()); + + getCampaign().removeUnit(unit.getId()); + } + + /** + * Disposes of a leased unit. Checks first that the unit exists, and has an attached lease - otherwise throws an + * exception. + * + * @param unit Unit to be removed + */ + public void cancelUnitLease(Unit unit) { + if (unit == null) { + LOGGER.debug("null unit passed into cancelUnitLease"); + return; + } + if (unit.getUnitLease() == null) { + LOGGER.debug("Unit {} with no lease passed into cancelUnitLease", unit.getName()); + return; + } + LocalDate thisDate = getCampaign().getLocalDate(); + Money lastMonthLease = unit.getUnitLease().getFinalLeaseCost(thisDate); + + getCampaign().getFinances() + .debit(TransactionType.UNIT_CANCEL_LEASE, + thisDate, + lastMonthLease, + "Final Monthly Lease of " + unit.getName()); getCampaign().removeUnit(unit.getId()); } /** * Sell all of the parts on hand. + * * @param part The part to sell. */ public void sellPart(Part part) { @@ -593,7 +671,8 @@ public void sellPart(Part part) { /** * Sell one or more units of a part. - * @param part The part to sell. + * + * @param part The part to sell. * @param quantity The amount to sell of the part. */ public void sellPart(Part part, int quantity) { @@ -619,14 +698,18 @@ public void sellPart(Part part, int quantity) { plural = "s"; } - getCampaign().getFinances().credit(TransactionType.EQUIPMENT_SALE, getCampaign().getLocalDate(), - cost, "Sale of " + quantity + " " + part.getName() + plural); + getCampaign().getFinances() + .credit(TransactionType.EQUIPMENT_SALE, + getCampaign().getLocalDate(), + cost, + "Sale of " + quantity + " " + part.getName() + plural); getWarehouse().removePart(part, quantity); } /** * Sell all of the ammo on hand. + * * @param ammo The ammo to sell. */ public void sellAmmo(AmmoStorage ammo) { @@ -637,7 +720,8 @@ public void sellAmmo(AmmoStorage ammo) { /** * Sell one or more shots of ammo. - * @param ammo The ammo to sell. + * + * @param ammo The ammo to sell. * @param shots The number of shots of ammo to sell. */ public void sellAmmo(AmmoStorage ammo, int shots) { @@ -658,14 +742,18 @@ public void sellAmmo(AmmoStorage ammo, int shots) { Money cost = ammo.getActualValue().multipliedBy(saleProportion); - getCampaign().getFinances().credit(TransactionType.EQUIPMENT_SALE, getCampaign().getLocalDate(), - cost, "Sale of " + shots + " " + ammo.getName()); + getCampaign().getFinances() + .credit(TransactionType.EQUIPMENT_SALE, + getCampaign().getLocalDate(), + cost, + "Sale of " + shots + " " + ammo.getName()); getWarehouse().removeAmmo(ammo, shots); } /** * Sell all of the armor on hand. + * * @param armor The armor to sell. */ public void sellArmor(Armor armor) { @@ -676,7 +764,8 @@ public void sellArmor(Armor armor) { /** * Sell one or more points of armor - * @param armor The armor to sell. + * + * @param armor The armor to sell. * @param points The number of points of armor to sell. */ public void sellArmor(Armor armor, int points) { @@ -697,14 +786,18 @@ public void sellArmor(Armor armor, int points) { Money cost = armor.getActualValue().multipliedBy(saleProportion); - getCampaign().getFinances().credit(TransactionType.EQUIPMENT_SALE, getCampaign().getLocalDate(), - cost, "Sale of " + points + " " + armor.getName()); + getCampaign().getFinances() + .credit(TransactionType.EQUIPMENT_SALE, + getCampaign().getLocalDate(), + cost, + "Sale of " + points + " " + armor.getName()); getWarehouse().removeArmor(armor, points); } /** * Removes one or more parts from its OmniPod. + * * @param part The omnipodded part. */ public void depodPart(Part part) { @@ -715,7 +808,8 @@ public void depodPart(Part part) { /** * Removes one or more parts from its OmniPod. - * @param part The omnipodded part. + * + * @param part The omnipodded part. * @param quantity The number of omnipodded parts to de-pod. */ public void depodPart(Part part, int quantity) { @@ -759,14 +853,18 @@ public void depodPart(Part part, int quantity) { /** * Tries to buys a refurbishment for a given part. + * * @param part The part being refurbished. + * * @return True if the refurbishment was purchased, otherwise false. */ public boolean buyRefurbishment(Part part) { if (getCampaignOptions().isPayForParts()) { - return getCampaign().getFinances().debit(TransactionType.EQUIPMENT_PURCHASE, - getCampaign().getLocalDate(), part.getActualValue(), - "Purchase of " + part.getName()); + return getCampaign().getFinances() + .debit(TransactionType.EQUIPMENT_PURCHASE, + getCampaign().getLocalDate(), + part.getActualValue(), + "Purchase of " + part.getName()); } else { return true; } @@ -774,8 +872,10 @@ public boolean buyRefurbishment(Part part) { /** * Tries to buy a part arriving in a given number of days. - * @param part The part to buy. + * + * @param part The part to buy. * @param transitDays The number of days until the new part arrives. + * * @return True if the part was purchased, otherwise false. */ public boolean buyPart(Part part, int transitDays) { @@ -784,9 +884,11 @@ public boolean buyPart(Part part, int transitDays) { /** * Tries to buy a part with a cost multiplier, arriving in a given number of days. - * @param part The part to buy. + * + * @param part The part to buy. * @param costMultiplier The cost multiplier for the purchase. - * @param transitDays The number of days until the new part arrives. + * @param transitDays The number of days until the new part arrives. + * * @return True if the part was purchased, otherwise false. */ public boolean buyPart(Part part, double costMultiplier, int transitDays) { @@ -794,9 +896,12 @@ public boolean buyPart(Part part, double costMultiplier, int transitDays) { if (getCampaignOptions().isPayForParts()) { Money cost = part.getActualValue().multipliedBy(costMultiplier); - if (getCampaign().getFinances().debit(TransactionType.EQUIPMENT_PURCHASE, - getCampaign().getLocalDate(), cost, "Purchase of " + part.getName())) { - addPart(part, transitDays, true); + if (getCampaign().getFinances() + .debit(TransactionType.EQUIPMENT_PURCHASE, + getCampaign().getLocalDate(), + cost, + "Purchase of " + part.getName())) { + addPart(part, transitDays, true); return true; } else { return false; diff --git a/MekHQ/src/mekhq/campaign/finances/Accountant.java b/MekHQ/src/mekhq/campaign/finances/Accountant.java index 2a642fe2d46..55bd07e7d5e 100644 --- a/MekHQ/src/mekhq/campaign/finances/Accountant.java +++ b/MekHQ/src/mekhq/campaign/finances/Accountant.java @@ -118,6 +118,14 @@ public Money getMaintenanceCosts() { return Money.zero(); } + public Money getLeaseCosts() { + LocalDate thisDate = campaign.getLocalDate(); + return getHangar().getUnitsStream() + .filter(Unit::hasLease) + .map(u -> u.getUnitLease().getLeaseCostNow(thisDate)) + .reduce(Money.zero(), Money::plus); + } + public Money getWeeklyMaintenanceCosts() { return getHangar().getUnitsStream().map(Unit::getWeeklyMaintenanceCost).reduce(Money.zero(), Money::plus); } @@ -147,6 +155,7 @@ public Money getOverheadExpenses() { *

If neither food nor housing expenses are enabled in the campaign options, this method returns zero.

* * @return a {@link Money} object representing the total monthly food and housing expenses for the campaign + * * @author Illiani * @since 0.50.06 */ diff --git a/MekHQ/src/mekhq/campaign/finances/Finances.java b/MekHQ/src/mekhq/campaign/finances/Finances.java index 72222077e20..ab1617cea45 100644 --- a/MekHQ/src/mekhq/campaign/finances/Finances.java +++ b/MekHQ/src/mekhq/campaign/finances/Finances.java @@ -33,6 +33,8 @@ */ package mekhq.campaign.finances; +import static mekhq.utilities.ReportingUtilities.spanOpeningWithCustomColor; + import java.io.BufferedWriter; import java.io.File; import java.io.PrintWriter; @@ -357,7 +359,7 @@ public void newDay(final Campaign campaign, final LocalDate yesterday, final Loc // Handle assets getAssets().forEach(asset -> asset.processNewDay(campaign, yesterday, today, this)); - // Handle peacetime operating expenses, payroll, and loan payments + // Handle peacetime operating expenses, payroll, loan payments, and leases if (isNewMonth) { if (campaignOptions.isUsePeacetimeCost()) { if (!campaignOptions.isShowPeacetimeCost()) { @@ -492,6 +494,23 @@ public void newDay(final Campaign campaign, final LocalDate yesterday, final Loc } } + // Leases - on the 1st, go get the cost of units in the hanger looking for leases. Charge money if possible. + // Even with the option disabled, some units may have lingering leases. + if (isNewMonth && !(Money.zero().equals(campaign.getAccountant().getLeaseCosts()))) { + Money leaseCosts = campaign.getAccountant().getLeaseCosts(); + if (debit(TransactionType.LEASE_PAYMENT, today, leaseCosts, resourceMap.getString("LeaseCosts.title"))) { + campaign.addReport(String.format(resourceMap.getString("LeaseCosts.text"), + leaseCosts.toAmountAndSymbolString())); + } else { + String color = spanOpeningWithCustomColor(MekHQ.getMHQOptions().getFontColorNegativeHexColor()); + String msg = String.format(color + + resourceMap.getString("LeaseCosts.insufficient.report") + + leaseCosts + + ""); + campaign.addReport(msg); + } + } + if ((getWentIntoDebt() != null) && !isInDebt()) { setWentIntoDebt(null); } diff --git a/MekHQ/src/mekhq/campaign/finances/Lease.java b/MekHQ/src/mekhq/campaign/finances/Lease.java new file mode 100644 index 00000000000..651c37c442f --- /dev/null +++ b/MekHQ/src/mekhq/campaign/finances/Lease.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * + * This file is part of MekHQ. + * + * MekHQ 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. + * + * MekHQ 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. MekHQ was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package mekhq.campaign.finances; + +import java.io.PrintWriter; +import java.time.LocalDate; + +import megamek.common.Entity; +import megamek.common.annotations.Nullable; +import megamek.logging.MMLogger; +import mekhq.campaign.unit.Unit; +import mekhq.utilities.MHQXMLUtility; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +public class Lease { + private static final MMLogger LOGGER = MMLogger.create(Lease.class); + private Money leaseCost; + private LocalDate acquisitionDate; + + //Multiplier for unit cost for leases is 0.5%, see CamOps p43 + //Using divisor instead for floating point accuracy + private final int leaseDivisor = 200; + + /** + * Leases are nominally attached to units while they are in the hanger. + */ + public Lease(LocalDate currentDay, Unit unit) { + acquisitionDate = currentDay; + leaseCost = unit.getSellValue().dividedBy(leaseDivisor); + } + + /** + * This constructor is only used for reloading leases from the XML. + */ + private Lease() { + } + + /** + * Gets the lease cost, for the accountant. Lease cost is prorated for the first month, so we need to check if + * yesterday was the first month. Should only be called on the 1st. It's also possible we get a lease object before + * the lease actually starts, if it takes a while to deliver the unit. Cost will be zero in this case. + * + * @param time The current campaign LocalDate + */ + @Nullable + public Money getLeaseCostNow(LocalDate time) { + if (time.getDayOfMonth() == 1) { + if (getLeaseStart().isBefore(time)) { + if (isLeaseFirstMonth(time.minusDays(1))) { + return getFirstLeaseCost(time); + } + return leaseCost; + } + return Money.zero(); + } else { + LOGGER.error("getLeaseCostNow(time) cannot be called on other days of the month then the 1st."); + return null; + } + } + + /** + * This is the total monthly cost for the entire lease. + */ + public Money getLeaseCost() { + return leaseCost; + } + + /** + * Leases can start at any time of the month, but they are only processed on the 1st by the accountant. + */ + public LocalDate getLeaseStart() { + return acquisitionDate; + } + + /** + * Utility function. Is this the first month of the lease? + * + * @param today The LocalDate to check with. No corrections done. + */ + private boolean isLeaseFirstMonth(LocalDate today) { + return (today.getYear() == acquisitionDate.getYear() && today.getMonth() == acquisitionDate.getMonth()); + } + + /** + * Gets the final cost of the lease remaining in the last month for use when ending a lease. If you call this in the + * same month you acquired the unit, only the days between lease start and now are counted. Can be called on any day + * of the month + * + * @return Money Prorated last payment of lease + */ + public Money getFinalLeaseCost(LocalDate today) { + int startDay = 1; + int currentDay = today.getDayOfMonth(); + if (isLeaseFirstMonth(today)) { + startDay = acquisitionDate.getDayOfMonth(); + } + int daysElapsed = currentDay - startDay + 1; + float fractionOfMonth = (float) daysElapsed / today.lengthOfMonth(); + return leaseCost.multipliedBy(fractionOfMonth); + } + + /** + * Gets the cost of the lease, prorated in the first month. Assumes that it's only called on the first day of the + * month, so we need to find yesterday for the last. + * + * @return Money Prorated first payment of lease + */ + @Nullable + public Money getFirstLeaseCost(LocalDate today) { + if (today.getDayOfMonth() == 1) { + int startDay = acquisitionDate.getDayOfMonth(); + LocalDate yesterday = today.minusDays(1); + int daysInMonth = yesterday.lengthOfMonth(); + int daysElapsed = yesterday.getDayOfMonth() - startDay + 1; + float fractionOfMonth = (float) daysElapsed / daysInMonth; + return leaseCost.multipliedBy(fractionOfMonth); + } else { + LOGGER.error("getFirstLeaseCost(today) cannot be called on other days of the month than the 1st."); + return null; + } + } + + /** + * This function checks to see if the Entity is of a leasable type. This is currently hardcoded to restrict it to + * Dropships and Jumpships only. + * + * @return True if unit is leasable + */ + public static boolean isLeasable(Entity check) { + return check.isDropShip() || check.isJumpShip(); + } + + public void writeToXML(final PrintWriter writer, int indent) { + MHQXMLUtility.writeSimpleXMLOpenTag(writer, indent++, "lease"); + MHQXMLUtility.writeSimpleXMLTag(writer, indent, "leaseCost", leaseCost); + MHQXMLUtility.writeSimpleXMLTag(writer, indent, "acquisitionDate", acquisitionDate); + MHQXMLUtility.writeSimpleXMLCloseTag(writer, --indent, "lease"); + } + + @Nullable + public static Lease generateInstanceFromXML(Node writerNode, Unit parseUnit) { + Lease savedLease = new Lease(); + NodeList childNodeList = writerNode.getChildNodes(); + + try { + for (int x = 0; x < childNodeList.getLength(); x++) { + Node childNode = childNodeList.item(x); + String nodeName = childNode.getNodeName(); + String nodeText = childNode.getTextContent(); + + if (nodeName.equalsIgnoreCase("leaseCost")) { + savedLease.leaseCost = Money.fromXmlString(nodeText); + } else if (nodeName.equalsIgnoreCase("acquisitionDate")) { + savedLease.acquisitionDate = LocalDate.parse(nodeText); + } + } + return savedLease; + } catch (Exception ex) { + LOGGER.error("Could not parse lease for unit {}", parseUnit.getId(), ex); + } + return null; + } +} + diff --git a/MekHQ/src/mekhq/campaign/finances/LeaseOrder.java b/MekHQ/src/mekhq/campaign/finances/LeaseOrder.java new file mode 100644 index 00000000000..91027c51ef3 --- /dev/null +++ b/MekHQ/src/mekhq/campaign/finances/LeaseOrder.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * + * This file is part of MekHQ. + * + * MekHQ 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. + * + * MekHQ 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. MekHQ was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package mekhq.campaign.finances; + +import static mekhq.utilities.ReportingUtilities.getPositiveColor; +import static mekhq.utilities.ReportingUtilities.messageSurroundedBySpanWithColor; + +import java.io.PrintWriter; + +import megamek.common.Entity; +import megamek.logging.MMLogger; +import mekhq.campaign.Campaign; +import mekhq.campaign.unit.UnitOrder; +import mekhq.utilities.MHQXMLUtility; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +public class LeaseOrder extends UnitOrder { + private static final MMLogger LOGGER = MMLogger.create(LeaseOrder.class); + + /* + * LeaseOrders is in the shopping list, it doesn't have a unit yet to attach to. + */ + public LeaseOrder(Entity entity, Campaign campaign) { + super(entity, campaign); + } + + // For the XML reader. + private LeaseOrder() { + } + + /** + * This function is called from Campaign newDay when successfully acquiring equipment, or when using the GM function + * to acquire things in the Acquisition panel. + * + * @param transitDays How long it takes for the unit to arrive. + * + * @return The string for a successful find report. + */ + @Override + public String find(int transitDays) { + this.getCampaign().getQuartermaster().createLeasedUnit((megamek.common.Entity) getNewEquipment(), transitDays); + return messageSurroundedBySpanWithColor(getPositiveColor(), + " unit found for leasing. It will be delivered in " + transitDays + " days."); + } + + /** + * Displays the correct name for the Procurement List, so they're not confused with standard orders. + * + * @return {String} Lease for UnitName + */ + @Override + public String getAcquisitionName() { + return "Lease for " + getName(); + } + + /** + * Leases don't actually COST anything when a unit is obtained, so this returns zero. + */ + @Override + public Money getTotalBuyCost() { + return Money.zero(); + } + + /** + * Leases don't actually COST anything when a unit is obtained, so this returns zero. + */ + @Override + public Money getBuyCost() { + return Money.zero(); + } + + @Override + public void writeToXML(final PrintWriter pw, int indent) { + MHQXMLUtility.writeSimpleXMLOpenTag(pw, indent++, "leaseOrder"); + pw.println(MHQXMLUtility.writeEntityToXmlString(getEntity(), indent, getCampaign().getEntities())); + MHQXMLUtility.writeSimpleXMLTag(pw, indent, "quantity", quantity); + MHQXMLUtility.writeSimpleXMLTag(pw, indent, "daysToWait", daysToWait); + MHQXMLUtility.writeSimpleXMLCloseTag(pw, --indent, "leaseOrder"); + } + + public static LeaseOrder generateInstanceFromXML(Node writerNode, Campaign campaign) { + LeaseOrder savedLeaseOrder = new LeaseOrder(); + savedLeaseOrder.setCampaign(campaign); + + NodeList childNodeList = writerNode.getChildNodes(); + + try { + for (int x = 0; x < childNodeList.getLength(); x++) { + Node childNode = childNodeList.item(x); + String nodeName = childNode.getNodeName(); + String nodeText = childNode.getTextContent(); + + if (nodeName.equalsIgnoreCase("quantity")) { + savedLeaseOrder.quantity = Integer.parseInt(nodeText); + } else if (nodeName.equalsIgnoreCase("daysToWait")) { + savedLeaseOrder.daysToWait = Integer.parseInt(nodeText); + } else if (nodeName.equalsIgnoreCase("entity")) { + savedLeaseOrder.entity = MHQXMLUtility.parseSingleEntityMul((Element) childNode, campaign); + } + } + } catch (Exception ex) { + LOGGER.error("Exception while parsing lease order from {}", childNodeList); + } + + savedLeaseOrder.initializeParts(false); + + return savedLeaseOrder; + } +} + diff --git a/MekHQ/src/mekhq/campaign/finances/enums/TransactionType.java b/MekHQ/src/mekhq/campaign/finances/enums/TransactionType.java index 372c36e3872..f5da04f43f0 100644 --- a/MekHQ/src/mekhq/campaign/finances/enums/TransactionType.java +++ b/MekHQ/src/mekhq/campaign/finances/enums/TransactionType.java @@ -44,6 +44,7 @@ public enum TransactionType { FINANCIAL_TERM_END_CARRYOVER("TransactionType.FINANCIAL_TERM_END_CARRYOVER.text", "TransactionType.FINANCIAL_TERM_END_CARRYOVER.toolTipText"), FINE("TransactionType.FINE.text", "TransactionType.FINE.toolTipText"), + LEASE_PAYMENT("TransactionType.LEASE_PAYMENT.text", "TransactionType.LEASE_PAYMENT.toolTipText"), LOAN_PAYMENT("TransactionType.LOAN_PAYMENT.text", "TransactionType.LOAN_PAYMENT.toolTipText"), LOAN_PRINCIPAL("TransactionType.LOAN_PRINCIPAL.text", "TransactionType.LOAN_PRINCIPAL.toolTipText"), MAINTENANCE("TransactionType.MAINTENANCE.text", "TransactionType.MAINTENANCE.toolTipText"), @@ -64,6 +65,7 @@ public enum TransactionType { TRANSPORTATION("TransactionType.TRANSPORTATION.text", "TransactionType.TRANSPORTATION.toolTipText"), UNIT_PURCHASE("TransactionType.UNIT_PURCHASE.text", "TransactionType.UNIT_PURCHASE.toolTipText"), UNIT_SALE("TransactionType.UNIT_SALE.text", "TransactionType.UNIT_SALE.toolTipText"), + UNIT_CANCEL_LEASE("TransactionType.UNIT_CANCEL_LEASE.text", "TransactionType.UNIT_CANCEL_LEASE.toolTipText"), BONUS_EXCHANGE("TransactionType.BONUS_EXCHANGE.text", "TransactionType.BONUS_EXCHANGE.toolTipText"), WEALTH("TransactionType.WEALTH.text", "TransactionType.WEALTH.toolTipText"); // endregion Enum Declarations @@ -121,6 +123,10 @@ public boolean isFine() { return this == FINE; } + public boolean isLeasePayment() { + return this == LEASE_PAYMENT; + } + public boolean isLoanPayment() { return this == LOAN_PAYMENT; } diff --git a/MekHQ/src/mekhq/campaign/market/ShoppingList.java b/MekHQ/src/mekhq/campaign/market/ShoppingList.java index 7f3b37057b9..7fa649052bc 100644 --- a/MekHQ/src/mekhq/campaign/market/ShoppingList.java +++ b/MekHQ/src/mekhq/campaign/market/ShoppingList.java @@ -44,6 +44,7 @@ import mekhq.MekHQ; import mekhq.campaign.Campaign; import mekhq.campaign.event.ProcurementEvent; +import mekhq.campaign.finances.LeaseOrder; import mekhq.campaign.finances.Money; import mekhq.campaign.parts.Part; import mekhq.campaign.parts.Refit; @@ -186,6 +187,8 @@ public void writeToXML(final PrintWriter pw, int indent) { // when we parse units and find refit kits that have not been found if ((shoppingItem instanceof Part) && !(shoppingItem instanceof Refit)) { ((Part) shoppingItem).writeToXML(pw, indent); + } else if (shoppingItem instanceof LeaseOrder) { + ((LeaseOrder) shoppingItem).writeToXML(pw, indent); } else if (shoppingItem instanceof UnitOrder) { ((UnitOrder) shoppingItem).writeToXML(pw, indent); } @@ -193,25 +196,30 @@ public void writeToXML(final PrintWriter pw, int indent) { MHQXMLUtility.writeSimpleXMLCloseTag(pw, --indent, "shoppingList"); } - public static ShoppingList generateInstanceFromXML(Node wn, Campaign c, Version version) { - ShoppingList retVal = new ShoppingList(); + public static ShoppingList generateInstanceFromXML(Node writerNode, Campaign campaign, Version version) { + ShoppingList savedShoppingList = new ShoppingList(); - NodeList nl = wn.getChildNodes(); + NodeList childNodeList = writerNode.getChildNodes(); try { - for (int x = 0; x < nl.getLength(); x++) { - Node wn2 = nl.item(x); - if (wn2.getNodeName().equalsIgnoreCase("part")) { - Part p = Part.generateInstanceFromXML(wn2, version); - p.setCampaign(c); - if (p instanceof IAcquisitionWork) { - retVal.getShoppingList().add((IAcquisitionWork) p); + for (int x = 0; x < childNodeList.getLength(); x++) { + Node childNode = childNodeList.item(x); + if (childNode.getNodeName().equalsIgnoreCase("part")) { + Part savedPart = Part.generateInstanceFromXML(childNode, version); + savedPart.setCampaign(campaign); + if (savedPart instanceof IAcquisitionWork) { + savedShoppingList.getShoppingList().add((IAcquisitionWork) savedPart); } - } else if (wn2.getNodeName().equalsIgnoreCase("unitOrder")) { - UnitOrder u = UnitOrder.generateInstanceFromXML(wn2, c); - u.setCampaign(c); - if (u.getEntity() != null) { - retVal.getShoppingList().add(u); + } else if (childNode.getNodeName().equalsIgnoreCase("unitOrder")) { + UnitOrder savedUnit = UnitOrder.generateInstanceFromXML(childNode, campaign); + savedUnit.setCampaign(campaign); + if (savedUnit.getEntity() != null) { + savedShoppingList.getShoppingList().add(savedUnit); + } + } else if (childNode.getNodeName().equalsIgnoreCase("leaseOrder")) { + LeaseOrder savedLeaseOrder = LeaseOrder.generateInstanceFromXML(childNode, campaign); + if (savedLeaseOrder.getEntity() != null) { + savedShoppingList.getShoppingList().add(savedLeaseOrder); } } } @@ -219,7 +227,7 @@ public static ShoppingList generateInstanceFromXML(Node wn, Campaign c, Version logger.error("", ex); } - return retVal; + return savedShoppingList; } public void restore() { diff --git a/MekHQ/src/mekhq/campaign/unit/Unit.java b/MekHQ/src/mekhq/campaign/unit/Unit.java index 557aa667e41..c8474dc0d22 100644 --- a/MekHQ/src/mekhq/campaign/unit/Unit.java +++ b/MekHQ/src/mekhq/campaign/unit/Unit.java @@ -90,6 +90,7 @@ import mekhq.campaign.event.PersonCrewAssignmentEvent; import mekhq.campaign.event.PersonTechAssignmentEvent; import mekhq.campaign.event.UnitArrivedEvent; +import mekhq.campaign.finances.Lease; import mekhq.campaign.finances.Money; import mekhq.campaign.force.Force; import mekhq.campaign.log.AssignmentLogger; @@ -196,6 +197,8 @@ public class Unit implements ITechnology { private MothballInfo mothballInfo; + private Lease unitLease; + public Unit() { this(null, null); } @@ -1391,8 +1394,11 @@ public boolean isDamaged() { } public Money getSellValue() { - Money partsValue = Money.zero(); + if (this.hasLease()) { + return Money.zero(); + } // don't count leased vehicles + Money partsValue = Money.zero(); partsValue = partsValue.plus(parts.stream() .map(x -> x.getActualValue().multipliedBy(x.getQuantity())) .collect(Collectors.toList())); @@ -2515,6 +2521,11 @@ public void writeToXML(final PrintWriter pw, int indent) { } // END new transports + //Leases + if (hasLease()) { + unitLease.writeToXML(pw, indent); + } + // Salvage status if (salvaged) { MHQXMLUtility.writeSimpleXMLTag(pw, indent, "salvaged", true); @@ -2731,6 +2742,9 @@ public static Unit generateInstanceFromXML(final Node wn, final Version version, retVal.lastMaintenanceReport = wn2.getTextContent(); } else if (wn2.getNodeName().equalsIgnoreCase("mothballInfo")) { retVal.mothballInfo = MothballInfo.generateInstanceFromXML(wn2, version); + } else if (wn2.getNodeName().equalsIgnoreCase("lease")) { + // Leases + retVal.unitLease = Lease.generateInstanceFromXML(wn2, retVal); } // Set up bay space values after we've loaded everything from the unit record // Used for older campaign @@ -6882,4 +6896,22 @@ public static PartQuality getRandomUnitQuality(int modifier) { "Unexpected value in mekhq/campaign/unit/Unit.java/getRandomUnitQuality: " + roll); }; } + + public void addLease(Lease lease) { + unitLease = lease; + } + + public void removeLease() { + unitLease = null; + } + + public boolean hasLease() { + return (unitLease != null); + } + + @Nullable + public Lease getUnitLease() { + return unitLease; + } + } diff --git a/MekHQ/src/mekhq/campaign/unit/UnitOrder.java b/MekHQ/src/mekhq/campaign/unit/UnitOrder.java index a6de9d8928f..73b23db0f0f 100644 --- a/MekHQ/src/mekhq/campaign/unit/UnitOrder.java +++ b/MekHQ/src/mekhq/campaign/unit/UnitOrder.java @@ -35,21 +35,19 @@ import java.io.PrintWriter; -import mekhq.utilities.ReportingUtilities; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; - import megamek.common.*; import megamek.common.loaders.EntityLoadingException; import megamek.logging.MMLogger; -import mekhq.MekHQ; import mekhq.campaign.Campaign; import mekhq.campaign.parts.Availability; import mekhq.campaign.parts.Part; import mekhq.campaign.personnel.Person; import mekhq.campaign.work.IAcquisitionWork; import mekhq.utilities.MHQXMLUtility; +import mekhq.utilities.ReportingUtilities; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; /** * We use an extension of unit to create a unit order acquisition work @@ -59,8 +57,8 @@ public class UnitOrder extends Unit implements IAcquisitionWork { private static final MMLogger logger = MMLogger.create(UnitOrder.class); - int quantity; - int daysToWait; + protected int quantity; + protected int daysToWait; public UnitOrder() { super(null, null); @@ -120,6 +118,7 @@ public String getAcquisitionName() { /** * @param quantity - the number of parts of this type + * * @return a string that gives a grammatical correct name based on the quantity */ @Override @@ -199,18 +198,47 @@ public void decrementDaysToWait() { public String find(int transitDays) { // TODO: probably get a duplicate entity if (getCampaign().getQuartermaster().buyUnit((Entity) getNewEquipment(), transitDays)) { - return " unit found. It will be delivered in " + transitDays + " days."; + return " unit found. It will be delivered in " + + transitDays + + " days."; } else { - return " You cannot afford this unit. Transaction cancelled."; + return " You cannot afford this unit. Transaction cancelled."; } } @Override public String failToFind() { - return " unit not found."; + return " unit not found."; + } + + private int getDropshipEraAcquisitionModifier(int year) { + int dropshipModifier = 0; + if (year > 3084) { + dropshipModifier = 0; + } else if (year > 3068) { + dropshipModifier = -2; + } else if (year > 3050) { + dropshipModifier = -1; + } else if (year > 2901) { + dropshipModifier = +0; + } else if (year > 2821) { + dropshipModifier = -2; + } else if (year > 2751) { + dropshipModifier = -6; + } else if (year > 2651) { + dropshipModifier = -6; + } else if (year > 2571) { + dropshipModifier = -5; + } else if (year > 2412) { + dropshipModifier = -4; + } else { + dropshipModifier = -3; + } + return dropshipModifier; } @Override @@ -225,7 +253,45 @@ public TargetRoll getAllAcquisitionMods() { target.addModifier(getCampaign().getCampaignOptions().getIsAcquisitionPenalty(), "Inner Sphere tech"); } // TODO: Fix weight classes - // TODO: aero large craft + if (entity instanceof Dropship || entity instanceof Jumpship) { + //FIXME ? + /* In theory, these should be the base target numbers of these vehicles. The base TNs in the book are + * static and don't adjust for Admin skill or anything. Instead, we're going to just kind of assume + * a base TN of 6 and adjust for windage. + * + */ + if (entity instanceof SpaceStation) { + target.addModifier((int) Math.ceil(entity.getCost(false) / 50000000) + 5 - 6, "Spacestation Base"); + } else if (entity instanceof Warship) { + double collars = ((Warship) entity).getDockingCollars().size(); + target.addModifier((int) (Math.ceil(Math.sqrt(entity.getWeight() / 5000)) + + Math.ceil(Math.sqrt(collars))) - 6, "Warship Base"); + } else if (entity instanceof Dropship) { + target.addModifier((int) Math.ceil(entity.getCost(false) / 50000000) + 5 - 6, "Dropship Base"); + } else { + int collars = ((Jumpship) entity).getDockingCollars().size(); + target.addModifier((int) Math.ceil(entity.getCost(false) / 100000000) + collars - 6, "Jumpship Base"); + } + + // Since the actual rules are ambiguous, we're ignoring 'uniqueness' until someone can come up with a table + // counting how many dropships/jumpships of which types exist in each particular era. + + // Other Misc Mods + if (getCampaign().isClanCampaign()) { + target.addModifier(-4, "Clan Force"); + } else if (getCampaign().getFaction().isGovernment()) { + target.addModifier(-2, "Government"); + } + if (entity.isMilitary()) { + target.addModifier(1, "Military Vessel"); + } + //Don't believe anyone recorded which faction manufacturers which dropship/jumpship. + + //Eras + int year = getCampaign().getGameYear(); + target.addModifier(getDropshipEraAcquisitionModifier(year), "Era"); + return target; + } // TODO: support vehicles // see EntityWeightClass.java in megamek for weight classes if (entity instanceof Mek) { @@ -321,8 +387,9 @@ public TargetRoll getAllAcquisitionMods() { @Override public AvailabilityValue getAvailability() { - return calcYearAvailability(getCampaign().getGameYear(), getCampaign().useClanTechBase(), - getCampaign().getTechFaction()); + return calcYearAvailability(getCampaign().getGameYear(), + getCampaign().useClanTechBase(), + getCampaign().getTechFaction()); } @Override diff --git a/MekHQ/src/mekhq/gui/adapter/ProcurementTableMouseAdapter.java b/MekHQ/src/mekhq/gui/adapter/ProcurementTableMouseAdapter.java index 6ef2d07e9f7..55607c20a20 100644 --- a/MekHQ/src/mekhq/gui/adapter/ProcurementTableMouseAdapter.java +++ b/MekHQ/src/mekhq/gui/adapter/ProcurementTableMouseAdapter.java @@ -45,9 +45,11 @@ import megamek.logging.MMLogger; import mekhq.MekHQ; import mekhq.campaign.event.ProcurementEvent; +import mekhq.campaign.finances.LeaseOrder; import mekhq.campaign.parts.Part; import mekhq.campaign.parts.enums.PartQuality; import mekhq.campaign.unit.Unit; +import mekhq.campaign.unit.UnitOrder; import mekhq.campaign.work.IAcquisitionWork; import mekhq.gui.CampaignGUI; import mekhq.gui.model.ProcurementTableModel; @@ -176,8 +178,20 @@ protected Optional createPopupMenu() { return Optional.of(popup); } + private boolean tryProcureEntity(IAcquisitionWork acquisition, int transitTime) { + boolean success; + final Object equipment = acquisition.getNewEquipment(); + if (acquisition instanceof LeaseOrder) { + success = gui.getCampaign().getQuartermaster().createLeasedUnit((Entity) equipment, transitTime); + } else { + success = gui.getCampaign().getQuartermaster().buyUnit((Entity) equipment, transitTime); + } + return success; + } + /** * @param acquisition the + * * @return whether the procurement attempt succeeded or not */ private boolean tryProcureOneItem(final IAcquisitionWork acquisition) { @@ -190,8 +204,8 @@ private boolean tryProcureOneItem(final IAcquisitionWork acquisition) { final boolean success; if (equipment instanceof Part) { success = gui.getCampaign().getQuartermaster().buyPart((Part) equipment, transitTime); - } else if (equipment instanceof Entity) { - success = gui.getCampaign().getQuartermaster().buyUnit((Entity) equipment, transitTime); + } else if (acquisition instanceof UnitOrder) { + success = tryProcureEntity((UnitOrder) acquisition, transitTime); } else { logger.error("Attempted to acquire unknown equipment of {}", acquisition.getAcquisitionName()); return false; @@ -207,14 +221,14 @@ private boolean tryProcureOneItem(final IAcquisitionWork acquisition) { + String.format( resources.getString("ProcurementTableMouseAdapter.CannotAffordToPurchaseItem.report") + "", - acquisition.getAcquisitionName())); + acquisition.getAcquisitionName())); } return success; } /** - * Processes the acquisition of a single item, adding it to the campaign as either a part or a unit, - * or logging an error if the acquisition type is unrecognized. + * Processes the acquisition of a single item, adding it to the campaign as either a part or a unit, or logging an + * error if the acquisition type is unrecognized. * *

The method performs the following steps:

*
    @@ -234,6 +248,7 @@ private boolean tryProcureOneItem(final IAcquisitionWork acquisition) { *
* * @param acquisition The acquisition work containing the item and metadata to process. Must not be {@code null}. + * * @throws NullPointerException If {@code acquisition} is {@code null}. */ private void addOneItem(final IAcquisitionWork acquisition) { diff --git a/MekHQ/src/mekhq/gui/adapter/UnitTableMouseAdapter.java b/MekHQ/src/mekhq/gui/adapter/UnitTableMouseAdapter.java index b24592efb77..76cd65bd00f 100644 --- a/MekHQ/src/mekhq/gui/adapter/UnitTableMouseAdapter.java +++ b/MekHQ/src/mekhq/gui/adapter/UnitTableMouseAdapter.java @@ -151,6 +151,7 @@ public class UnitTableMouseAdapter extends JPopupMenuAdapter { public static final String COMMAND_HIRE_FULL = "HIRE_FULL"; public static final String COMMAND_DISBAND = "DISBAND"; public static final String COMMAND_SELL = "SELL"; + public static final String COMMAND_UNLEASE = "CANCEL_LEASE"; public static final String COMMAND_LOSS = "LOSS"; public static final String COMMAND_MAINTENANCE_REPORT = "MAINTENANCE_REPORT"; public static final String COMMAND_QUIRKS = "QUIRKS"; @@ -279,6 +280,20 @@ public void actionPerformed(ActionEvent action) { } } } + } else if (command.equals(COMMAND_UNLEASE)) { + for (Unit unit : units) { + if (!unit.isDeployed() && unit.hasLease()) { + Money unleaseValue = unit.getUnitLease().getFinalLeaseCost(unit.getCampaign().getLocalDate()); + String text = unleaseValue.toAmountAndSymbolString(); + if (0 == + JOptionPane.showConfirmDialog(null, + "Do you really want to cancel the lease on " + unit.getName() + " for " + text, + "Cancel Unit Lease?", + JOptionPane.YES_NO_OPTION)) { + gui.getCampaign().getQuartermaster().cancelUnitLease(unit); + } + } + } } else if (command.equals(COMMAND_LOSS)) { for (Unit unit : units) { if (0 == @@ -1109,7 +1124,7 @@ protected Optional createPopupMenu() { } // sell unit - if (!allDeployed && gui.getCampaign().getCampaignOptions().isSellUnits()) { + if (!allDeployed && gui.getCampaign().getCampaignOptions().isSellUnits() && !unit.hasLease()) { popup.addSeparator(); menuItem = new JMenuItem("Sell Unit"); menuItem.setActionCommand(COMMAND_SELL); @@ -1117,6 +1132,15 @@ protected Optional createPopupMenu() { popup.add(menuItem); } + // cancel lease + if (!allDeployed && gui.getCampaign().getCampaignOptions().isSellUnits() && unit.hasLease()) { + popup.addSeparator(); + menuItem = new JMenuItem("Cancel Lease"); + menuItem.setActionCommand(COMMAND_UNLEASE); + menuItem.addActionListener(this); + popup.add(menuItem); + } + // region GM Mode // GM mode - only show to GMs if (isGM) { diff --git a/MekHQ/src/mekhq/gui/campaignOptions/contents/FinancesTab.java b/MekHQ/src/mekhq/gui/campaignOptions/contents/FinancesTab.java index 1009e844ff5..9fc5e93ab54 100644 --- a/MekHQ/src/mekhq/gui/campaignOptions/contents/FinancesTab.java +++ b/MekHQ/src/mekhq/gui/campaignOptions/contents/FinancesTab.java @@ -75,6 +75,7 @@ public class FinancesTab { private CampaignOptionsHeaderPanel financesGeneralOptions; private JPanel pnlGeneralOptions; private JCheckBox useLoanLimitsBox; + private JCheckBox trackLeasesBox; private JCheckBox usePercentageMaintenanceBox; private JCheckBox useExtendedPartsModifierBox; private JCheckBox usePeacetimeCostBox; @@ -181,6 +182,7 @@ private void initializeGeneralOptionsTab() { // General Options pnlGeneralOptions = new JPanel(); useLoanLimitsBox = new JCheckBox(); + trackLeasesBox = new JCheckBox(); usePercentageMaintenanceBox = new JCheckBox(); useExtendedPartsModifierBox = new JCheckBox(); usePeacetimeCostBox = new JCheckBox(); @@ -236,7 +238,8 @@ private void initializeGeneralOptionsTab() { public JPanel createFinancesGeneralOptionsTab() { // Header financesGeneralOptions = new CampaignOptionsHeaderPanel("FinancesGeneralTab", - getImageDirectory() + "logo_star_league.png", 6); + getImageDirectory() + "logo_star_league.png", + 6); // Contents pnlGeneralOptions = createGeneralOptionsPanel(); @@ -385,6 +388,8 @@ private JPanel createGeneralOptionsPanel() { // Contents useLoanLimitsBox = new CampaignOptionsCheckBox("UseLoanLimitsBox"); useLoanLimitsBox.addMouseListener(createTipPanelUpdater(financesGeneralOptions, "UseLoanLimitsBox")); + trackLeasesBox = new CampaignOptionsCheckBox("TrackLeasesBox"); + trackLeasesBox.addMouseListener(createTipPanelUpdater(financesGeneralOptions, "UseTrackLeasesBox")); usePercentageMaintenanceBox = new CampaignOptionsCheckBox("UsePercentageMaintenanceBox"); usePercentageMaintenanceBox.addMouseListener(createTipPanelUpdater(financesGeneralOptions, "UsePercentageMaintenanceBox")); @@ -421,6 +426,9 @@ private JPanel createGeneralOptionsPanel() { layout.gridwidth = 2; panel.add(useLoanLimitsBox, layout); + layout.gridy++; + panel.add(trackLeasesBox, layout); + layout.gridy++; panel.add(usePercentageMaintenanceBox, layout); @@ -592,7 +600,10 @@ private void initializePriceMultipliersTab() { public JPanel createPriceMultipliersTab() { // Header priceMultipliersHeader = new CampaignOptionsHeaderPanel("PriceMultipliersTab", - getImageDirectory() + "logo_clan_stone_lion.png", true, true, 2); + getImageDirectory() + "logo_clan_stone_lion.png", + true, + true, + 2); // Contents pnlGeneralMultipliers = createGeneralMultipliersPanel(); @@ -857,6 +868,7 @@ public void applyCampaignOptionsToCampaign(@Nullable CampaignOptions presetCampa // General Options options.setLoanLimits(useLoanLimitsBox.isSelected()); + options.setTrackLeases(trackLeasesBox.isSelected()); options.setUsePercentageMaint(usePercentageMaintenanceBox.isSelected()); options.setUseExtendedPartsModifier(useExtendedPartsModifierBox.isSelected()); options.setUsePeacetimeCost(usePeacetimeCostBox.isSelected()); @@ -925,6 +937,7 @@ public void loadValuesFromCampaignOptions(@Nullable CampaignOptions presetCampai // General Options useLoanLimitsBox.setSelected(options.isUseLoanLimits()); + trackLeasesBox.setSelected(options.isTrackLeases()); usePercentageMaintenanceBox.setSelected(options.isUsePercentageMaint()); useExtendedPartsModifierBox.setSelected(options.isUseExtendedPartsModifier()); usePeacetimeCostBox.setSelected(options.isUsePeacetimeCost()); diff --git a/MekHQ/src/mekhq/gui/dialog/MekHQUnitSelectorDialog.java b/MekHQ/src/mekhq/gui/dialog/MekHQUnitSelectorDialog.java index 0c41957b385..20b78cb3607 100644 --- a/MekHQ/src/mekhq/gui/dialog/MekHQUnitSelectorDialog.java +++ b/MekHQ/src/mekhq/gui/dialog/MekHQUnitSelectorDialog.java @@ -49,8 +49,8 @@ import javax.swing.RowFilter; import megamek.client.ui.Messages; -import megamek.client.ui.dialogs.advancedsearch.MekSearchFilter; import megamek.client.ui.dialogs.UnitLoadingDialog; +import megamek.client.ui.dialogs.advancedsearch.MekSearchFilter; import megamek.client.ui.dialogs.unitSelectorDialogs.AbstractUnitSelectorDialog; import megamek.common.Entity; import megamek.common.EntityWeightClass; @@ -62,7 +62,10 @@ import megamek.common.annotations.Nullable; import mekhq.MekHQ; import mekhq.campaign.Campaign; +import mekhq.campaign.finances.Lease; +import mekhq.campaign.finances.LeaseOrder; import mekhq.campaign.parts.enums.PartQuality; +import mekhq.campaign.unit.Unit; import mekhq.campaign.unit.UnitOrder; import mekhq.campaign.unit.UnitTechProgression; import mekhq.utilities.MHQInternationalization; @@ -75,6 +78,8 @@ public class MekHQUnitSelectorDialog extends AbstractUnitSelectorDialog { private JButton buttonBuy; private JButton buttonAddGM; + protected JButton buttonLease; + protected JButton buttonLeaseGM; private static final String TARGET_UNKNOWN = "--"; @@ -118,6 +123,30 @@ public void updateOptionValues() { } } + public void leaseButtonHandler(boolean isGM) { + // Block acquisition if the unit type is unsupported + if (getSelectedEntity() == null) { + return; + } + Entity entity = selectedUnit.getEntity(); + // Remove this check if we ever allow other units to be leased. + if (!(Lease.isLeasable(entity))) { + return; //TODO: Add a dialog box for why it doesn't work + } + + if (isGM) { + PartQuality quality = PartQuality.QUALITY_D; + if (campaign.getCampaignOptions().isUseRandomUnitQualities()) { + quality = UnitOrder.getRandomUnitQuality(0); + } + Unit unit = campaign.addNewUnit(selectedUnit.getEntity(), false, 0, quality); + unit.addLease(new Lease(campaign.getLocalDate(), unit)); + } else { + LeaseOrder thisLease = new LeaseOrder(entity, campaign); + campaign.getShoppingList().addShoppingItem(thisLease, 1, campaign); + } + } + /** * This is the initialization function for all the buttons involved in this panel. */ @@ -131,6 +160,8 @@ protected JPanel createButtonsPanel() { buttonBuy = new JButton(); buttonAddGM = new JButton(); buttonShowBV = new JButton(); + buttonLease = new JButton(); + buttonLeaseGM = new JButton(); if (addToCampaign) { //This branch is for purchases and adding to the hanger directly. @@ -140,12 +171,28 @@ protected JPanel createButtonsPanel() { buttonBuy.setEnabled(false); panelButtons.add(buttonBuy, new GridBagConstraints()); + if (campaign.getCampaignOptions().isTrackLeases()) { + buttonLease.setText(Messages.getString("MekSelectorDialog.Lease", TARGET_UNKNOWN)); + buttonLease.setName("buttonLease"); + buttonLease.addActionListener(e -> leaseButtonHandler(false)); + panelButtons.add(buttonLease, new GridBagConstraints()); + buttonLease.setEnabled(false); + } + if (campaign.isGM()) { buttonAddGM.setText(Messages.getString("MekSelectorDialog.AddGM")); buttonAddGM.setName("buttonAddGM"); buttonAddGM.addActionListener(evt -> addGM()); buttonAddGM.setEnabled(false); + panelButtons.add(buttonAddGM, new GridBagConstraints()); + if (campaign.getCampaignOptions().isTrackLeases()) { + buttonLeaseGM.setText(Messages.getString("MekSelectorDialog.LeaseGM", TARGET_UNKNOWN)); + buttonLeaseGM.setName("buttonLeaseGM"); + buttonLeaseGM.addActionListener(e -> leaseButtonHandler(true)); + panelButtons.add(buttonLeaseGM, new GridBagConstraints()); + buttonLeaseGM.setEnabled(false); + } } buttonClose = new JButton(Messages.getString("Close")); buttonClose.setName("buttonClose"); @@ -267,6 +314,14 @@ public Entity getSelectedEntity() { buttonBuy.setText(Messages.getString("MekSelectorDialog.Buy", TARGET_UNKNOWN)); buttonBuy.setToolTipText(null); buttonAddGM.setEnabled(false); + if (campaign.getCampaignOptions().isTrackLeases()) { + buttonLease.setEnabled(false); + buttonLease.setText(Messages.getString("MekSelectorDialog.Lease", TARGET_UNKNOWN)); + buttonLease.setToolTipText(null); + buttonLeaseGM.setEnabled(false); + buttonLeaseGM.setText(Messages.getString("MekSelectorDialog.LeaseGM", TARGET_UNKNOWN)); + buttonLeaseGM.setToolTipText(null); + } } } else { selectedUnit = new UnitOrder(entity, campaign); @@ -277,6 +332,14 @@ public Entity getSelectedEntity() { buttonBuy.setText(Messages.getString("MekSelectorDialog.Buy", target.getValueAsString())); buttonBuy.setToolTipText(target.getDesc()); buttonAddGM.setEnabled(true); + if (campaign.getCampaignOptions().isTrackLeases() && (Lease.isLeasable(entity))) { + buttonLease.setEnabled(true); + buttonLeaseGM.setEnabled(true); + buttonLease.setText(Messages.getString("MekSelectorDialog.Lease", target.getValueAsString())); + buttonLeaseGM.setText(Messages.getString("MekSelectorDialog.LeaseGM", target.getValueAsString())); + buttonLease.setToolTipText(target.getDesc()); + buttonLeaseGM.setToolTipText(target.getDesc()); + } } } @@ -299,17 +362,17 @@ protected Entity refreshUnitView() { * This function is to simplify logic in filterUnits. It runs a series of checks to determine if a unit is valid * within the current filtering context. * - * @param unitSummary The unit being evaluated. + * @param unitSummary The unit being evaluated. * @param weightClassSelectorIndex The current weight class selection - * @param tech The current tech selection - * @param techLevelMatch whether the current tech selection matches - * @param checkSupportVee Whether the special 'Support Vehicle' unit type was selected - * @param unitTypeSelectorIndex Which unit type is currently selected (Depends on the combo box order!) + * @param tech The current tech selection + * @param techLevelMatch whether the current tech selection matches + * @param checkSupportVee Whether the special 'Support Vehicle' unit type was selected + * @param unitTypeSelectorIndex Which unit type is currently selected (Depends on the combo box order!) * * @return true if the unit passes all filters and allowed, false otherwise */ - private boolean isAllowedUnit(MekSummary unitSummary, int weightClassSelectorIndex, ITechnology tech, boolean techLevelMatch, - boolean checkSupportVee, int unitTypeSelectorIndex) { + private boolean isAllowedUnit(MekSummary unitSummary, int weightClassSelectorIndex, ITechnology tech, + boolean techLevelMatch, boolean checkSupportVee, int unitTypeSelectorIndex) { if (enableYearLimits && (unitSummary.getYear() > allowedYear)) { return false; } @@ -322,7 +385,8 @@ private boolean isAllowedUnit(MekSummary unitSummary, int weightClassSelectorInd if (canonOnly && !unitSummary.isCanon()) { return false; } - if ((weightClassSelectorIndex != unitSummary.getWeightClass()) && weightClassSelectorIndex != EntityWeightClass.SIZE) { + if ((weightClassSelectorIndex != unitSummary.getWeightClass()) && + weightClassSelectorIndex != EntityWeightClass.SIZE) { return false; } if ((tech == null) || !campaign.isLegal(tech)) { @@ -386,7 +450,12 @@ public boolean include(Entry entry) break; } } - return isAllowedUnit(mek, weightClassSelectorIndex, tech, techLevelMatch, checkSupportVee, unitTypeSelectorIndex); + return isAllowedUnit(mek, + weightClassSelectorIndex, + tech, + techLevelMatch, + checkSupportVee, + unitTypeSelectorIndex); } }; } catch (PatternSyntaxException ignored) { diff --git a/MekHQ/src/mekhq/gui/view/UnitViewPanel.java b/MekHQ/src/mekhq/gui/view/UnitViewPanel.java index 07ef2e122c4..3e5d3445451 100644 --- a/MekHQ/src/mekhq/gui/view/UnitViewPanel.java +++ b/MekHQ/src/mekhq/gui/view/UnitViewPanel.java @@ -59,7 +59,8 @@ /** * A custom panel that gets filled in with goodies from a unit record - * @author Jay Lawson (jaylawson39 at yahoo.com) + * + * @author Jay Lawson (jaylawson39 at yahoo.com) */ public class UnitViewPanel extends JScrollablePanel { private Unit unit; @@ -98,7 +99,7 @@ private void initComponents() { pnlStats = new JPanel(); final ResourceBundle resourceMap = ResourceBundle.getBundle("mekhq.resources.UnitViewPanel", - MekHQ.getMHQOptions().getLocale()); + MekHQ.getMHQOptions().getLocale()); setLayout(new GridBagLayout()); @@ -106,7 +107,7 @@ private void initComponents() { Image image = FluffImageHelper.getFluffImage(entity); if (null != image) { // fluff image exists so use custom ImgLabel to get full mek porn - lblImage = new ImgLabel(image); + lblImage = new ImgLabel(image); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 1; gridBagConstraints.gridy = 0; @@ -154,7 +155,11 @@ private void initComponents() { txtReadout.setContentType(resourceMap.getString("txtReadout.contentType")); txtReadout.setEditable(false); txtReadout.setFont(Font.decode(resourceMap.getString("txtReadout.font"))); - txtReadout.setText("
" + mview.getMekReadoutBasic() + "
" + mview.getMekReadoutLoadout() + "
"); + txtReadout.setText("
" + + mview.getMekReadoutBasic() + + "
" + + mview.getMekReadoutLoadout() + + "
"); txtReadout.setBorder(RoundedLineBorder.createRoundedLineBorder("Technical Readout")); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 0; @@ -291,7 +296,13 @@ private void fillStats(ResourceBundle resourceMap) { pnlStats.add(lblCost, gridBagConstraints); txtCost.setName("lblCost2"); - txtCost.setText(unit.getSellValue().toAmountAndSymbolString()); + if (unit.hasLease()) { + txtCost.setText(unit.getUnitLease().getLeaseCost().toAmountAndSymbolString() + + " " + + resourceMap.getString("lblLease.text")); + } else { + txtCost.setText(unit.getSellValue().toAmountAndSymbolString()); + } gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 1; gridBagConstraints.gridy = 4; diff --git a/MekHQ/unittests/mekhq/campaign/finances/LeaseTest.java b/MekHQ/unittests/mekhq/campaign/finances/LeaseTest.java new file mode 100644 index 00000000000..66e5e7b6527 --- /dev/null +++ b/MekHQ/unittests/mekhq/campaign/finances/LeaseTest.java @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * + * This file is part of MekHQ. + * + * MekHQ 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. + * + * MekHQ 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. MekHQ was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +package mekhq.campaign.finances; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.time.LocalDate; + +import megamek.common.Dropship; +import megamek.common.Mek; +import mekhq.campaign.unit.Unit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class LeaseTest { + + Unit mockUnit; + + //Arrange + @BeforeEach + void beforeEach() { + Unit unit = new Unit(); + mockUnit = Mockito.spy(unit); + Mockito.doReturn(Money.of(600000.00)).when(mockUnit).getSellValue(); + + Lease testLease = new Lease(LocalDate.parse("3025-03-31"), mockUnit); + mockUnit.addLease(testLease); + } + + @Test + void getLeaseCost_whenSetUp_hasValidBaseLeaseCost() { + //Act + Money testCost = mockUnit.getUnitLease().getLeaseCost(); + + //Assert + assertEquals(Money.of(3000), testCost); + } + + @Test + void getLeaseCostNow_whenFullMonthCost_calculatesBaseLeaseCostCorrectly() { + //Arrange + LocalDate testDate = LocalDate.parse("3025-05-01"); + + //Act + Money testCost = mockUnit.getUnitLease().getLeaseCostNow(testDate); + + //Assert + assertEquals(Money.of(3000), testCost); + } + + @Test + void getLeaseCostNow_whenLeaseHasNotStarted_equalsZeroCost() { + //Arrange + LocalDate testDate = LocalDate.parse("3025-03-01"); + + //Act + Money testCost = mockUnit.getUnitLease().getLeaseCostNow(testDate); + + //Assert + assertEquals(Money.zero(), testCost); + } + + @Test + void getLeaseCostNow_whenNotThe1st_returnsNull() { + //Arrange + LocalDate testDate = LocalDate.parse("3025-03-02"); + + //Act + Money testCost = mockUnit.getUnitLease().getLeaseCostNow(testDate); + + //Assert + assertNull(testCost); + } + + @Test + void getFirstLeaseCost_whenInFirstMonth_shouldGivePartialLeaseAmount() { + LocalDate date = LocalDate.parse("3025-04-01"); + + //Act + Money testCost = mockUnit.getUnitLease().getFirstLeaseCost(date); + + //Assert + assertEquals(Math.round(Money.of(3000.00 / 31).getAmount().doubleValue()), + Math.round(testCost.getAmount().doubleValue()), + "This should be a single day's lease"); + } + + @Test + void getFirstLeaseCost_whenNotThe1st_returnsNull() { + LocalDate date = LocalDate.parse("3025-04-02"); + + //Act + Money testCost = mockUnit.getUnitLease().getFirstLeaseCost(date); + + //Assert + assertNull(testCost); + } + + @Test + void getLeaseStart_whenInstantiated_shouldReturnStartDate() { + //Arrange + LocalDate testDate = LocalDate.parse("3025-03-31"); + + //Act + LocalDate date = mockUnit.getUnitLease().getLeaseStart(); + + //Assert + assertEquals(date, testDate); + } + + @Test + void getFinalLeaseCost_whenCalled_shouldProrateLeaseCostUsedInMonth() { + //Arrange + LocalDate testDate = LocalDate.parse("3025-06-05"); + + //Act + Money testLeaseCost = mockUnit.getUnitLease().getFinalLeaseCost(testDate); + + // 5 days of lease cost in June - rounded, because doubles. + double testCost = Math.round(testLeaseCost.getAmount().doubleValue()); + double cost = Math.round(Money.of(5 * 3000.00 / 30).getAmount().doubleValue()); + + //Assert + assertEquals(cost, testCost); + } + + @Test + void isLeaseable_whenUnitIsDropship_shouldReturnTrue() { + //Arrange + Dropship mockDropship = mock(Dropship.class); + Mockito.doReturn(true).when(mockDropship).isDropShip(); + + //Act + boolean returnValue = Lease.isLeasable(mockDropship); + + //Assert + assertTrue(returnValue); + } + + @Test + void isLeaseable_whenUnitIsMek_shouldReturnFalse() { + //Arrange + Mek mockMek = mock(Mek.class); + Mockito.doReturn(false).when(mockMek).isDropShip(); + + //Act + boolean returnValue = Lease.isLeasable(mockMek); + + //Assert + assertFalse(returnValue); + } +}