diff --git a/MekHQ/docs/Stratcon and Against the Bot/MekHQ Morale.pdf b/MekHQ/docs/Stratcon and Against the Bot/MekHQ Morale.pdf index 5a504c5560..c62599e1f0 100644 Binary files a/MekHQ/docs/Stratcon and Against the Bot/MekHQ Morale.pdf and b/MekHQ/docs/Stratcon and Against the Bot/MekHQ Morale.pdf differ diff --git a/MekHQ/resources/mekhq/resources/Campaign.properties b/MekHQ/resources/mekhq/resources/Campaign.properties index f83243e46a..a1fcdde7df 100644 --- a/MekHQ/resources/mekhq/resources/Campaign.properties +++ b/MekHQ/resources/mekhq/resources/Campaign.properties @@ -84,3 +84,7 @@ newAtBScenario.format=New scenario "{0}" will occur on {1}. atbScenarioToday.format=Scenario "{0}" is today, deploy a force from your TOE! atbScenarioTodayWithForce.format=Scenario "{0}" is today, {1} has been deployed! generalFallbackAddress.text=Commander +garrisonDutyRouted.text=Long-ranged sensors detect no enemy activity in the AO. +contractMoraleReport.text=Current enemy condition is %s on contract %s.\ +
\ +
%s \ No newline at end of file diff --git a/MekHQ/resources/mekhq/resources/ContractMarketDialog.properties b/MekHQ/resources/mekhq/resources/ContractMarketDialog.properties index b8f7b3021d..5a4d46bfec 100644 --- a/MekHQ/resources/mekhq/resources/ContractMarketDialog.properties +++ b/MekHQ/resources/mekhq/resources/ContractMarketDialog.properties @@ -99,3 +99,16 @@ messageChallengeVeryHard.text=We've reviewed the mission details, and it's our d
\
If you are committed to this course of action, confirm your deployment.\
Otherwise, you may return to review more suitable assignments.
+messageChallengeGarrison.text=You have selected a garrison assignment. As part of this contract,\ + \ your unit will be responsible for maintaining a defensive presence in your assigned Sectors.\ + \ However, we cannot predict when - or even if - you will be called to fight. Furthermore, we\ + \ have no reliable intelligence on potential adversaries or the scale of any engagement that may\ + \ arise.\ +
The provided data is for estimation purposes only. Situational developments may occur\ + \ without warning, and you should be prepared for anything - from prolonged quiet to sudden,\ + \ large-scale conflict. Flexibility and readiness will be essential for the success of this\ + \ contract.\ +
\ +
If you are prepared to assume this role, confirm your deployment.\ +
Otherwise, you may return to review other opportunities.
diff --git a/MekHQ/src/mekhq/campaign/Campaign.java b/MekHQ/src/mekhq/campaign/Campaign.java index 2d7aa84b72..ced9b4ef54 100644 --- a/MekHQ/src/mekhq/campaign/Campaign.java +++ b/MekHQ/src/mekhq/campaign/Campaign.java @@ -3648,6 +3648,14 @@ && getLocation().getJumpPath().getLastSystem().getId().equals(contract.getSystem contract.setStartAndEndDate(getLocalDate().plusDays((int) Math.ceil(getLocation().getTransitTime()))); addReport("The start and end dates of " + contract.getName() + " have been shifted to reflect the current ETA."); + + if (campaignOptions.isUseStratCon() && contract.getMoraleLevel().isRouted()) { + LocalDate newRoutEndDate = contract.getStartDate() + .plusMonths(Math.max(1, Compute.d6() - 3)) + .minusDays(1); + contract.setRoutEndDate(newRoutEndDate); + } + continue; } @@ -3782,14 +3790,22 @@ private void processNewDayATB() { } for (AtBContract contract : getActiveAtBContracts()) { - contract.checkMorale(this, getLocalDate()); + AtBMoraleLevel oldMorale = contract.getMoraleLevel(); - AtBMoraleLevel morale = contract.getMoraleLevel(); + contract.checkMorale(this, getLocalDate()); + AtBMoraleLevel newMorale = contract.getMoraleLevel(); - String report = "Current enemy condition is " + morale + " on contract " - + contract.getName() + "

" + morale.getToolTipText(); + String report = ""; + if (contract.getContractType().isGarrisonDuty()) { + report = resources.getString("garrisonDutyRouted.text"); + } else if (oldMorale != newMorale) { + report = String.format(resources.getString("contractMoraleReport.text"), + newMorale, contract.getName(), newMorale.getToolTipText()); + } - addReport(report); + if (!report.isBlank()) { + addReport(report); + } } } diff --git a/MekHQ/src/mekhq/campaign/mission/AtBContract.java b/MekHQ/src/mekhq/campaign/mission/AtBContract.java index 9b45452716..da8806c288 100644 --- a/MekHQ/src/mekhq/campaign/mission/AtBContract.java +++ b/MekHQ/src/mekhq/campaign/mission/AtBContract.java @@ -29,6 +29,7 @@ import megamek.client.ui.swing.util.PlayerColour; import megamek.common.Compute; import megamek.common.Entity; +import megamek.common.TargetRoll; import megamek.common.UnitType; import megamek.common.enums.Gender; import megamek.common.enums.SkillLevel; @@ -39,6 +40,7 @@ import mekhq.campaign.event.MissionChangedEvent; import mekhq.campaign.finances.Money; import mekhq.campaign.force.Force; +import mekhq.campaign.force.Lance; import mekhq.campaign.market.enums.UnitMarketType; import mekhq.campaign.mission.atb.AtBScenarioFactory; import mekhq.campaign.mission.enums.AtBContractType; @@ -48,10 +50,10 @@ import mekhq.campaign.personnel.backgrounds.BackgroundsController; import mekhq.campaign.personnel.enums.PersonnelRole; import mekhq.campaign.personnel.enums.Phenotype; -import mekhq.campaign.rating.IUnitRating; import mekhq.campaign.stratcon.StratconCampaignState; import mekhq.campaign.stratcon.StratconContractDefinition; import mekhq.campaign.stratcon.StratconContractInitializer; +import mekhq.campaign.stratcon.StratconTrackState; import mekhq.campaign.unit.Unit; import mekhq.campaign.universe.Faction; import mekhq.campaign.universe.Factions; @@ -74,6 +76,7 @@ import java.util.List; import java.util.*; +import static java.lang.Math.ceil; import static java.lang.Math.round; import static megamek.client.ratgenerator.ModelRecord.NETWORK_NONE; import static megamek.client.ratgenerator.UnitTable.findTable; @@ -84,6 +87,9 @@ import static megamek.common.enums.SkillLevel.parseFromString; import static mekhq.campaign.mission.AtBDynamicScenarioFactory.getEntity; import static mekhq.campaign.mission.BotForceRandomizer.UNIT_WEIGHT_UNSPECIFIED; +import static mekhq.campaign.rating.IUnitRating.*; +import static mekhq.campaign.stratcon.StratconContractInitializer.seedPreDeployedForces; +import static mekhq.campaign.stratcon.StratconRulesManager.processMassRout; import static mekhq.campaign.universe.Factions.getFactionLogo; import static mekhq.campaign.universe.fameAndInfamy.BatchallFactions.BATCHALL_FACTIONS; import static mekhq.gui.dialog.HireBulkPersonnelDialog.overrideSkills; @@ -181,6 +187,16 @@ protected AtBContract() { this(null); } + /** + * Sets the end date of the rout. + * This should only be applied on contracts whose morale equals ROUTED + * + * @param routEnd the {@code LocalDate} representing the end date of the rout + */ + public void setRoutEndDate(LocalDate routEnd) { + this.routEnd = routEnd; + } + public AtBContract(String name) { super(name, "Independent"); employerCode = "IND"; @@ -193,9 +209,9 @@ public AtBContract(String name) { setContractType(AtBContractType.GARRISON_DUTY); setAllySkill(REGULAR); - allyQuality = IUnitRating.DRAGOON_C; + allyQuality = DRAGOON_C; setEnemySkill(REGULAR); - enemyQuality = IUnitRating.DRAGOON_C; + enemyQuality = DRAGOON_C; allyBotName = "Ally"; enemyBotName = "Enemy"; setAllyCamouflage(new Camouflage(Camouflage.COLOUR_CAMOUFLAGE, PlayerColour.RED.name())); @@ -424,21 +440,105 @@ public static boolean isMinorPower(final String factionCode) { public void checkMorale(Campaign campaign, LocalDate today) { // Check whether enemy forces have been reinforced, and whether any current rout continues // beyond its expected date - boolean routContinue = Compute.randomInt(4) < 3; + boolean routContinue = Compute.randomInt(4) == 0; // If there is a rout end date, and it's past today, update morale and enemy state accordingly if (routEnd != null && !routContinue) { if (today.isAfter(routEnd)) { setMoraleLevel(AtBMoraleLevel.STALEMATE); routEnd = null; + updateEnemy(campaign, today); // mix it up a little + + if (campaign.getCampaignOptions().isUseStratCon()) { + for (StratconTrackState track : getStratconCampaignState().getTracks()) { + seedPreDeployedForces(this, campaign, track, true); + } + } } else { setMoraleLevel(AtBMoraleLevel.ROUTED); } return; } - // Initialize counters for victories and defeats + TargetRoll targetNumber = new TargetRoll(); + + // Confidence: + int enemySkillRating = getEnemySkill().getAdjustedValue() - 2; + int allySkillRating = getAllySkill().getAdjustedValue() - 2; + + if (getCommandRights().isIndependent()) { + allySkillRating = (campaign.getCampaignOptions().getUnitRatingMethod().isFMMR() ? getAllySkill() + : campaign.getReputation().getAverageSkillLevel()).getAdjustedValue(); + allySkillRating -= 2; + } + + final LocalDate THE_GREAT_REFUSAL = LocalDate.of(3060, 4, 12); + + if (campaign.getLocalDate().isBefore(THE_GREAT_REFUSAL)) { + if (getEnemy().isClan() && !getEmployerFaction().isClan()) { + enemySkillRating++; + } else if (!getEnemy().isClan() && getEmployerFaction().isClan()) { + allySkillRating++; + } + } + + int confidence = enemySkillRating - allySkillRating; + targetNumber.addModifier(confidence, "confidence"); + + // Reliability: + int reliability = getEnemyQuality(); + + Faction enemy = getEnemy(); + if (enemy.isClan()) { + reliability = Math.max(5, reliability + 1); + } + + reliability = switch (reliability) { + case DRAGOON_F -> -1; + case DRAGOON_D -> { + if (Compute.randomInt(1) == 0) { + yield -1; + } else { + yield 0; + } + } + case DRAGOON_C -> 0; + case DRAGOON_B -> { + if (Compute.randomInt(1) == 0) { + yield 0; + } else { + yield +1; + } + } + case DRAGOON_A -> +1; + default -> { // DRAGOON_ASTAR + if (Compute.randomInt(1) == 0) { + yield +1; + } else { + yield +2; + } + } + }; + + if (enemy.isRebel() + || enemy.isMinorPower() + || enemy.isMercenary() + || enemy.isPirate()) { + reliability--; + } else if (enemy.isClan()) { + reliability++; + } + + targetNumber.addModifier(reliability, "reliability"); + + // Force Type (unimplemented) + // TODO once we have force types defined on the StratCon map, we should handle modifiers here. + // 'Mek or Aircraft == +1 + // Vehicle == +0 + // Infantry == -1 (if unsupported) + + // Performance int victories = 0; int defeats = 0; LocalDate lastMonth = today.minusMonths(1); @@ -464,10 +564,6 @@ public void checkMorale(Campaign campaign, LocalDate today) { } } - // Calculate various modifiers for morale - int enemySkillModifier = getEnemySkill().getAdjustedValue() - REGULAR.getAdjustedValue(); - int allySkillModifier = getAllySkill().getAdjustedValue() - REGULAR.getAdjustedValue(); - int performanceModifier = 0; if (victories > (defeats * 2)) { @@ -480,22 +576,58 @@ public void checkMorale(Campaign campaign, LocalDate today) { performanceModifier++; } - int miscModifiers = moraleMod; + targetNumber.addModifier(performanceModifier, "performanceModifier"); + + // Balance of Power + int balanceOfPower = 0; + if (campaign.getCampaignOptions().isUseStratCon()) { + int playerForceCount = 0; - // Additional morale modifications depending on faction properties - if (Factions.getInstance().getFaction(enemyCode).isPirate()) { - miscModifiers -= 2; - } else if (Factions.getInstance().getFaction(enemyCode).isRebel() - || isMinorPower(enemyCode) - || Factions.getInstance().getFaction(enemyCode).isMercenary()) { - miscModifiers -= 1; - } else if (Factions.getInstance().getFaction(enemyCode).isClan()) { - miscModifiers += 2; + for (Lance lance : campaign.getLances().values()) { + try { + Force force = campaign.getForce(lance.getForceId()); + + if (force.isCombatForce()) { + playerForceCount++; + } + } catch (Exception ex) { + logger.error(String.format("Failed to fetch force %s: %s", + lance.getForceId(), ex.getMessage())); + } + } + + playerForceCount = (int) ceil((double) playerForceCount / campaign.getActiveContracts().size()); + + if (getCommandRights().isHouse() || getCommandRights().isLiaison()) { + playerForceCount = (int) round(playerForceCount * 1.25); + } else if (getCommandRights().isIntegrated()) { + playerForceCount = (int) round(playerForceCount * 1.5); + } + + int enemyForceCount = 0; + for (StratconTrackState track : getStratconCampaignState().getTracks()) { + enemyForceCount += track.getScenarios().size(); + } + + if (playerForceCount >= (enemyForceCount * 3)) { + balanceOfPower = -6; + } else if (playerForceCount >= (enemyForceCount * 2)) { + balanceOfPower = -4; + } else if (playerForceCount > enemyForceCount) { + balanceOfPower = -2; + } else if (enemyForceCount >= (playerForceCount * 3)) { + balanceOfPower = 6; + } else if (enemyForceCount >= (playerForceCount * 2)) { + balanceOfPower = 4; + } else if (enemyForceCount > playerForceCount) { + balanceOfPower = 2; + } } + targetNumber.addModifier(balanceOfPower, "balanceOfPower"); + // Total morale modifier calculation - int totalModifier = enemySkillModifier - allySkillModifier + performanceModifier + miscModifiers; - int roll = Compute.d6(2) + totalModifier; + int roll = Compute.d6(2) + targetNumber.getValue(); // Morale level determination based on roll value final AtBMoraleLevel[] moraleLevels = AtBMoraleLevel.values(); @@ -519,13 +651,10 @@ public void checkMorale(Campaign campaign, LocalDate today) { " The contract will conclude tomorrow."); setEndDate(today.plusDays(1)); } - } - // Process the results of the reinforcement roll - if (!getMoraleLevel().isRouted() && !routContinue) { - setMoraleLevel(moraleLevels[Math.min(getMoraleLevel().ordinal() + 1, moraleLevels.length - 1)]); - campaign.addReport("Long ranged scans have detected the arrival of additional enemy forces."); - return; + if (campaign.getCampaignOptions().isUseStratCon()) { + processMassRout(getStratconCampaignState(), true); + } } // Reset external morale modifier @@ -585,7 +714,7 @@ public int getRepairLocation(final int unitRating) { repairLocation = Unit.SITE_FACILITY_MAINTENANCE; } - if (unitRating >= IUnitRating.DRAGOON_B) { + if (unitRating >= DRAGOON_B) { repairLocation++; } @@ -866,7 +995,7 @@ public void checkEvents(Campaign c) { case 6: final String unitName = c.getUnitMarket().addSingleUnit(c, UnitMarketType.EMPLOYER, MEK, getEmployerFaction(), - IUnitRating.DRAGOON_F, 50); + DRAGOON_F, 50); if (unitName != null) { text += String.format( "Surplus Sale: %s offered by employer on the unit market", @@ -1814,7 +1943,7 @@ public int calculateContractDifficulty(Campaign campaign) { double difference = enemyPower - playerPower; double percentDifference = (difference / playerPower) * 100; - int mappedValue = (int) Math.ceil(Math.abs(percentDifference) / 20); + int mappedValue = (int) ceil(Math.abs(percentDifference) / 20); if (percentDifference < 0) { mappedValue = 5 - mappedValue; } else { diff --git a/MekHQ/src/mekhq/campaign/mission/enums/AtBContractType.java b/MekHQ/src/mekhq/campaign/mission/enums/AtBContractType.java index 155507eb8e..442e386053 100644 --- a/MekHQ/src/mekhq/campaign/mission/enums/AtBContractType.java +++ b/MekHQ/src/mekhq/campaign/mission/enums/AtBContractType.java @@ -18,8 +18,6 @@ */ package mekhq.campaign.mission.enums; -import java.util.ResourceBundle; - import megamek.common.Compute; import megamek.logging.MMLogger; import mekhq.MekHQ; @@ -28,6 +26,8 @@ import mekhq.campaign.mission.AtBScenario; import mekhq.campaign.universe.enums.EraFlag; +import java.util.ResourceBundle; + public enum AtBContractType { // TODO: Missing Camops Mission Types: ASSASSINATION, ESPIONAGE, MOLE_HUNTING, OBSERVATION_RAID, // RETAINER, SABOTAGE, TERRORISM, HIGH_RISK @@ -138,6 +138,16 @@ public boolean isGarrisonType() { public boolean isRaidType() { return isDiversionaryRaid() || isObjectiveRaid() || isReconRaid() || isExtractionRaid(); } + + /** + * Checks if the given contract is offensive. + * + * @return {@code true} if the contract is of raid type, pirate hunting, or guerrilla warfare; + * {@code false} otherwise + */ + public boolean isOffensive() { + return isRaidType() || isPirateHunting() || isGuerrillaWarfare() || isPlanetaryAssault(); + } // endregion Boolean Comparison Methods public int calculateLength(final boolean variable, final AtBContract contract) { diff --git a/MekHQ/src/mekhq/campaign/stratcon/StratconContractInitializer.java b/MekHQ/src/mekhq/campaign/stratcon/StratconContractInitializer.java index 2a18790323..5158a35a45 100644 --- a/MekHQ/src/mekhq/campaign/stratcon/StratconContractInitializer.java +++ b/MekHQ/src/mekhq/campaign/stratcon/StratconContractInitializer.java @@ -19,19 +19,32 @@ package mekhq.campaign.stratcon; import megamek.common.Compute; +import megamek.common.annotations.Nullable; import megamek.logging.MMLogger; import mekhq.campaign.Campaign; import mekhq.campaign.force.Force; import mekhq.campaign.mission.*; import mekhq.campaign.mission.ScenarioForceTemplate.ForceAlignment; import mekhq.campaign.mission.atb.AtBScenarioModifier; +import mekhq.campaign.mission.enums.AtBContractType; +import mekhq.campaign.mission.enums.AtBMoraleLevel; import mekhq.campaign.mission.enums.ContractCommandRights; import mekhq.campaign.stratcon.StratconContractDefinition.ObjectiveParameters; import mekhq.campaign.stratcon.StratconContractDefinition.StrategicObjectiveType; +import mekhq.campaign.universe.Faction; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Random; + +import static java.lang.Math.ceil; +import static java.lang.Math.round; +import static megamek.common.Coords.ALL_DIRECTIONS; +import static mekhq.campaign.rating.IUnitRating.*; +import static mekhq.campaign.stratcon.StratconRulesManager.addHiddenExternalScenario; +import static mekhq.campaign.stratcon.StratconRulesManager.processMassRout; /** * This class handles StratCon state initialization when a contract is signed. @@ -70,6 +83,7 @@ public static void initializeCampaignState(AtBContract contract, Campaign campai int maximumTrackIndex = Math.max(0, contract.getRequiredLances() / NUM_LANCES_PER_TRACK); int planetaryTemperature = campaign.getLocation().getPlanet().getTemperature(campaign.getLocalDate()); + double planetaryDiameter = campaign.getLocation().getPlanet().getDiameter(); for (int x = 0; x < maximumTrackIndex; x++) { int scenarioOdds = contractDefinition.getScenarioOdds() @@ -78,7 +92,7 @@ public static void initializeCampaignState(AtBContract contract, Campaign campai .get(Compute.randomInt(contractDefinition.getDeploymentTimes().size())); StratconTrackState track = initializeTrackState(NUM_LANCES_PER_TRACK, scenarioOdds, deploymentTime, - planetaryTemperature); + planetaryTemperature, planetaryDiameter); track.setDisplayableName(String.format("Sector %d", x)); campaignState.addTrack(track); } @@ -94,7 +108,7 @@ public static void initializeCampaignState(AtBContract contract, Campaign campai .get(Compute.randomInt(contractDefinition.getDeploymentTimes().size())); StratconTrackState track = initializeTrackState(oddLanceCount, scenarioOdds, deploymentTime, - planetaryTemperature); + planetaryTemperature, planetaryDiameter); track.setDisplayableName(String.format("Sector %d", campaignState.getTracks().size())); campaignState.addTrack(track); } @@ -184,6 +198,11 @@ public static void initializeCampaignState(AtBContract contract, Campaign campai false, Collections.emptyList()); } + // Initialize non-objective scenarios + for (StratconTrackState track : campaignState.getTracks()) { + seedPreDeployedForces(contract, campaign, track, true); + } + // clean up objectives for integrated command: // we're still going to have all the objective facilities and scenarios // but the player has no control over where they go, so they're @@ -195,28 +214,96 @@ public static void initializeCampaignState(AtBContract contract, Campaign campai } } + // Determine starting morale + if (contract.getContractType().isGarrisonDuty()) { + contract.setMoraleLevel(AtBMoraleLevel.ROUTED); + + LocalDate routEnd = contract.getStartDate().plusMonths(Math.max(1, Compute.d6() - 3)).minusDays(1); + contract.setRoutEndDate(routEnd); + + processMassRout(campaignState, true); + } else { + contract.checkMorale(campaign, campaign.getLocalDate()); + + if (contract.getMoraleLevel().isRouted()) { + contract.setMoraleLevel(AtBMoraleLevel.CRITICAL); + } + } + // now we're done } + /** + * Seeds pre-deployed (hidden) forces in a {@link StratconTrackState}, taking into account + * contract type, enemy quality, and enemy faction. + * + * @param contract the current contract + * @param campaign the current campaign. + * @param track the relevant {@link StratconTrackState} + * @param isEnemy whether we are seeding forces for the enemy, or player's allies + */ + public static int seedPreDeployedForces(AtBContract contract, Campaign campaign, + StratconTrackState track, boolean isEnemy) { + // TODO remove reductions once we have friendly forces deploying too + final int CLAN_CLUSTER = 11; // 22 Stars, reduced to 11 + final int IS_BATTALION = 14; // 27 Lances, reduced to 14 + final int COMSTAR_LEVEL_IV = 18; // 36 Level IIs, reduced to 18 + + int quality = isEnemy ? contract.getEnemyQuality() : contract.getAllyQuality(); + double multiplier = switch (quality) { + case DRAGOON_F -> 0.25; + case DRAGOON_D -> 0.5; + case DRAGOON_C -> 0.75; + case DRAGOON_A -> 1.5; + case DRAGOON_ASTAR -> 2; + default -> 1; // DRAGOON_B + }; + + AtBContractType contractType = contract.getContractType(); + + if (contractType.isPirateHunting() || contractType.isGarrisonType()) { + multiplier *= 0.5; + } else if (contractType.isPlanetaryAssault()) { + multiplier *= 2; + } + + int elementCount = IS_BATTALION; + + Faction faction = isEnemy ? contract.getEnemy() : contract.getEmployerFaction(); + + if (faction.isClan()) { + elementCount = CLAN_CLUSTER; + } else if (faction.isComStarOrWoB()) { + elementCount = COMSTAR_LEVEL_IV; + } + + elementCount = (int) ceil(elementCount * multiplier); + + for (int i = 0; i < elementCount; i++) { + addHiddenExternalScenario(campaign, contract, track, null, false); + } + + return elementCount; + } + /** * Set up initial state of a track, dimensions are based on number of assigned * lances. */ public static StratconTrackState initializeTrackState(int numLances, int scenarioOdds, - int deploymentTime, int planetaryTemp) { - // to initialize a track, - // 1. we set the # of required lances - // 2. set the track size to a total of numlances * 28 hexes, a rectangle that is - // wider than it is taller - // the idea being to create a roughly rectangular playing field that, - // if one deploys a scout lance each week to a different spot, can be more or - // less fully covered - + int deploymentTime, int planetaryTemp, + double planetaryDiameter) { StratconTrackState retVal = new StratconTrackState(); retVal.setRequiredLanceCount(numLances); + // calculate planet surface area + double radius = planetaryDiameter / 2; + double planetSurfaceArea = 4 * Math.PI * Math.pow(radius, 2); + // This gives us a decently sized track, without it feeling too large + planetSurfaceArea /= 1000000; + // set width and height - int numHexes = numLances * 28; + int numHexes = (int) round(planetSurfaceArea); int height = (int) Math.floor(Math.sqrt(numHexes)); int width = numHexes / height; retVal.setWidth(width); @@ -287,7 +374,7 @@ private static void initializeTrackFacilities(StratconTrackState trackState, int sf.setStrategicObjective(strategicObjective); sf.getLocalModifiers().addAll(modifiers); - StratconCoords coords = getUnoccupiedCoords(trackState); + StratconCoords coords = getUnoccupiedCoords(trackState, false); if (coords == null) { logger.warn(String.format("Unable to place facility on track %s," + @@ -341,7 +428,7 @@ private static void initializeObjectiveScenarios(Campaign campaign, AtBContract ScenarioTemplate template = StratconScenarioFactory.getSpecificScenario( objectiveScenarios.get(Compute.randomInt(objectiveScenarios.size()))); - StratconCoords coords = getUnoccupiedCoords(trackState); + StratconCoords coords = getUnoccupiedCoords(trackState, false); if (coords == null) { logger.error(String.format("Unable to place objective scenario on track %s," + @@ -387,30 +474,123 @@ private static void initializeObjectiveScenarios(Campaign campaign, AtBContract } /** - * Utility function that, given a track state, picks a random set of unoccupied - * coordinates. + * Searches for a suitable coordinate on the given {@link StratconTrackState}. + * The suitability of a coordinate is determined by the absence of a scenario in the coordinate + * and the absence of a facility or the presence of a player-allied facility (determined by the + * value of allowPlayerFacilities). + *

+ * The method iterates through all possible coordinates and shuffles them to ensure randomness. + * A random coordinate is then selected from the list of suitable coordinates and returned. + * + * @param trackState the {@link StratconTrackState} object on which to perform the search + * @param allowPlayerFacilities a {@link boolean} value indicating whether player-owned facilities + * should be considered suitable + * @return a {@link StratconCoords} object representing the coordinates of a suitable location, + * or {@code null} if no suitable location was found + */ + public static @Nullable StratconCoords getUnoccupiedCoords(StratconTrackState trackState, boolean allowPlayerFacilities) { + int trackHeight = trackState.getHeight(); + int trackWidth = trackState.getWidth(); + + List suitableCoords = new ArrayList<>(); + + for (int y = 0; y < trackHeight; y++) { + for (int x = 0; x < trackWidth; x++) { + StratconCoords coords = new StratconCoords(x, y); + + if (trackState.getScenario(coords) != null) { + continue; + } + + if (trackState.getFacility(coords) == null) { + suitableCoords.add(coords); + continue; + } + + if (trackState.getFacility(coords).getOwner() != ForceAlignment.Opposing) { + if (allowPlayerFacilities) { + suitableCoords.add(coords); + } + } + } + } + + Collections.shuffle(suitableCoords); + + if (suitableCoords.isEmpty()) { + return null; + } else { + int randomIndex = new Random().nextInt(suitableCoords.size()); + return suitableCoords.get(randomIndex); + } + } + + /** + * Searches for and returns a suitable adjacent coordinate to the given origin coordinates on + * the provided {@link StratconTrackState}. + * The method checks all the possible directions and considers a coordinate suitable if it + * doesn't contain a scenario and if it either doesn't contain a facility or contains a + * player-allied one. + * If there are multiple suitable coordinates, one is chosen at random. + * + * @param originCoords the {@link StratconCoords} around which to search for a suitable coordinate + * @param trackState the {@link StratconTrackState} on which to perform the search + * @param weightPlayerForces whether to place greater emphasis on player-allied forces and facilities. + * @return a {@link StratconCoords} object representing the coordinates of a suitable adjacent + * location, or {code null} if no suitable location was found. */ - public static StratconCoords getUnoccupiedCoords(StratconTrackState trackState) { - // Maximum number of attempts - int maxAttempts = trackState.getWidth() * trackState.getHeight(); - int attempts = 0; - - int x = Compute.randomInt(trackState.getWidth()); - int y = Compute.randomInt(trackState.getHeight()); - StratconCoords coords = new StratconCoords(x, y); - - while ((trackState.getFacility(coords) != null || trackState.getScenario(coords) != null) && attempts < maxAttempts) { - x = Compute.randomInt(trackState.getWidth()); - y = Compute.randomInt(trackState.getHeight()); - coords = new StratconCoords(x, y); - attempts++; + public static @Nullable StratconCoords getUnoccupiedAdjacentCoords(StratconCoords originCoords, + StratconTrackState trackState, + boolean weightPlayerForces) { + List suitableCoords = new ArrayList<>(); + List playerForceCoords = new ArrayList<>(); + List playerFacilityCoords = new ArrayList<>(); + + for (int direction : ALL_DIRECTIONS) { + StratconCoords newCoords = originCoords.translate(direction); + + if (trackState.getScenario(newCoords) != null) { + continue; + } + + if (trackState.getFacility(newCoords) == null) { + suitableCoords.add(newCoords); + continue; + } + + if (trackState.getFacility(newCoords).getOwner() != ForceAlignment.Opposing) { + suitableCoords.add(newCoords); + + if (weightPlayerForces) { + playerFacilityCoords.add(newCoords); + } + } + + if (trackState.getAssignedForceCoords().containsValue(newCoords)) { + playerForceCoords.add(newCoords); + } } - if (attempts == maxAttempts) { + if (suitableCoords.isEmpty()) { return null; } - return coords; + Random random = new Random(); + + if (weightPlayerForces) { + if (!playerFacilityCoords.isEmpty()) { + int randomIndex = random.nextInt(playerFacilityCoords.size()); + return playerFacilityCoords.get(randomIndex); + } + + if (!playerForceCoords.isEmpty()) { + int randomIndex = random.nextInt(playerForceCoords.size()); + return playerForceCoords.get(randomIndex); + } + } + + int randomIndex = random.nextInt(suitableCoords.size()); + return suitableCoords.get(randomIndex); } /** diff --git a/MekHQ/src/mekhq/campaign/stratcon/StratconRulesManager.java b/MekHQ/src/mekhq/campaign/stratcon/StratconRulesManager.java index ec47576c29..1af1c3f5f7 100644 --- a/MekHQ/src/mekhq/campaign/stratcon/StratconRulesManager.java +++ b/MekHQ/src/mekhq/campaign/stratcon/StratconRulesManager.java @@ -52,10 +52,12 @@ import java.util.*; import java.util.stream.Collectors; +import static mekhq.campaign.force.Force.FORCE_NONE; import static mekhq.campaign.mission.ScenarioMapParameters.MapLocation.AllGroundTerrain; import static mekhq.campaign.mission.ScenarioMapParameters.MapLocation.LowAtmosphere; import static mekhq.campaign.mission.ScenarioMapParameters.MapLocation.Space; import static mekhq.campaign.mission.ScenarioMapParameters.MapLocation.SpecificGroundTerrain; +import static mekhq.campaign.stratcon.StratconContractInitializer.getUnoccupiedAdjacentCoords; import static mekhq.campaign.stratcon.StratconContractInitializer.getUnoccupiedCoords; /** @@ -119,6 +121,7 @@ public static void generateScenariosForTrack(Campaign campaign, AtBContract cont } else if (contract.getMoraleLevel().isOverwhelming()) { scenarioRolls += 2; } + for (int scenarioIndex = 0; scenarioIndex < scenarioRolls; scenarioIndex++) { int targetNum = calculateScenarioOdds(track, contract, false); @@ -126,7 +129,7 @@ public static void generateScenariosForTrack(Campaign campaign, AtBContract cont // generate a scenario if (!availableForceIDs.isEmpty() && (Compute.randomInt(100) < targetNum)) { // pick random coordinates and force to drive the scenario - StratconCoords scenarioCoords = getUnoccupiedCoords(track); + StratconCoords scenarioCoords = getUnoccupiedCoords(track, true); if (scenarioCoords == null) { logger.warn("Target track is full, skipping scenario generation"); @@ -138,7 +141,10 @@ public static void generateScenariosForTrack(Campaign campaign, AtBContract cont if (track.getAssignedCoordForces().containsKey(scenarioCoords)) { StratconScenario scenario = generateScenarioForExistingForces(scenarioCoords, track.getAssignedCoordForces().get(scenarioCoords), contract, campaign, track); - generatedScenarios.add(scenario); + + if (scenario != null) { + generatedScenarios.add(scenario); + } continue; } @@ -164,7 +170,10 @@ public static void generateScenariosForTrack(Campaign campaign, AtBContract cont } StratconScenario scenario = setupScenario(scenarioCoords, randomForceID, campaign, contract, track); - generatedScenarios.add(scenario); + + if (scenario != null) { + generatedScenarios.add(scenario); + } } } @@ -190,31 +199,47 @@ public static void generateScenariosForTrack(Campaign campaign, AtBContract cont } /** - * Creates a new StratCon scenario, placing it in an unoccupied location on the specified track. - * If no track is specified, a random one will be chosen. - * An optional scenario template can be applied. - * This method is based on {@code generateScenariosForTrack()}, designed to simplify the - * process by which external classes can add new StratCon scenarios. + * Generates a StratCon scenario. + * This is a utility method that allows us to generate a scenario quickly without specifying + * track state and scenario template. * - * @param campaign The campaign object encapsulating the current campaign state. - * @param contract The contract associated with the current scenario. - * @param track The {@link StratconTrackState} the scenario should be assigned to, or - * {@code null} to select a random track. - * @param template A specific {@link ScenarioTemplate} to use for scenario generation, - * or {@code null} to select scenario template randomly. + * @param campaign The current campaign. + * @param contract The contract associated with the scenario. + * @return A newly generated {@link StratconScenario}, or {@code null} if scenario creation fails. */ - public static void generateExternalScenario(Campaign campaign, AtBContract contract, - @Nullable StratconTrackState track, @Nullable ScenarioTemplate template) { + public static @Nullable StratconScenario generateExternalScenario(Campaign campaign, AtBContract contract) { + return generateExternalScenario(campaign, contract, null, null, + null, false); + } + + /** + * Generates a new StratCon scenario using advanced configuration. + * It provides a scenario based on a given campaign, contract, track, template. + * This is meant for scenario control on a higher level than the overloading methods. + * + * @param campaign The current campaign. + * @param contract The contract associated with the scenario. + * @param track The {@link StratconTrackState} the scenario should be assigned to, or + * {@code null} to select a random track. + * @param scenarioCoords The {@link StratconCoords} where in the track to place the scenario, or + * {@code null} to select a random hex. If populated, {@code track} cannot be + * {@code null} + * @param template A specific {@link ScenarioTemplate} to use for scenario generation, + * or {@code null} to select scenario template randomly. + * @param allowPlayerFacilities Whether the scenario is allowed to spawn on top of + * player-allied facilities. + * @return A newly generated {@link StratconScenario}, or {@code null} if scenario creation fails. + */ + public static @Nullable StratconScenario generateExternalScenario(Campaign campaign, AtBContract contract, + @Nullable StratconTrackState track, @Nullable StratconCoords scenarioCoords, + @Nullable ScenarioTemplate template, boolean allowPlayerFacilities) { // If we're not generating for a specific track, randomly pick one. if (track == null) { - List tracks = contract.getStratconCampaignState().getTracks(); - Random rand = new Random(); - - if (!tracks.isEmpty()) { - track = tracks.get(rand.nextInt(tracks.size())); - } else { - logger.error("No tracks available. Aborting scenario generation."); - return; + track = getRandomTrack(contract); + + if (track == null) { + logger.error("Failed to generate a random track, aborting scenario generation."); + return null; } } @@ -226,11 +251,13 @@ public static void generateExternalScenario(Campaign campaign, AtBContract contr Map> sortedAvailableForceIDs = sortForcesByMapType(availableForceIDs, campaign); // Select the target coords. - StratconCoords scenarioCoords = getUnoccupiedCoords(track); + if (scenarioCoords == null) { + scenarioCoords = getUnoccupiedCoords(track, allowPlayerFacilities); + } if (scenarioCoords == null) { - logger.warn("Target track is full, aborting scenario generation"); - return; + logger.warn("Target track is full, aborting scenario generation."); + return null; } // If forces are already assigned to the target coordinates, use those instead of randomly @@ -266,14 +293,107 @@ public static void generateExternalScenario(Campaign campaign, AtBContract contr // If we haven't generated a scenario yet, it's because we need to pick a random force. if (scenario == null) { - int randomForceIndex = Compute.randomInt(availableForceIDs.size()); - int randomForceID = availableForceIDs.get(randomForceIndex); + int availableForces = availableForceIDs.size(); + int randomForceID = FORCE_NONE; + + if (availableForces > 0) { + int randomForceIndex = Compute.randomInt(availableForces); + randomForceID = availableForceIDs.get(randomForceIndex); + } scenario = setupScenario(scenarioCoords, randomForceID, campaign, contract, track, template); } + if (scenario == null) { + return null; + } + // We end by finalizing the scenario finalizeBackingScenario(campaign, contract, track, autoAssignLances, scenario); + + // We return the scenario in case we want to make specific changes. + return scenario; + } + + /** + * Adds a {@link StratconScenario} to the specified contract. This scenario is cloaked so will + * not be visible until the player uncovers it. + * If no {@link StratconTrackState} or {@link ScenarioTemplate} is provided, random one will be + * picked. + * + * @param campaign The current campaign. + * @param contract The {@link AtBContract} associated with the scenario. + * @param trackState The {@link StratconTrackState} in which the scenario occurs. + * If {@code null}, a random trackState is selected. + * @param template The {@link ScenarioTemplate} for the scenario. + * If {@code null}, the default template is used. + * @param allowPlayerFacilities Whether the scenario is allowed to spawn on top of + * player-allied facilities. + * + * @return The created {@link StratconScenario} or @code null}, + * if no {@link ScenarioTemplate} is found or if all coordinates in the provided + * {@link StratconTrackState} are occupied (and therefore, scenario placement is not possible). + */ + public static @Nullable StratconScenario addHiddenExternalScenario(Campaign campaign, AtBContract contract, + @Nullable StratconTrackState trackState, + @Nullable ScenarioTemplate template, + boolean allowPlayerFacilities) { + // If we're not generating for a specific track, randomly pick one. + if (trackState == null) { + trackState = getRandomTrack(contract); + + if (trackState == null) { + logger.error("Failed to generate a random track, aborting scenario generation."); + return null; + } + } + + StratconCoords coords = getUnoccupiedCoords(trackState, allowPlayerFacilities); + + if (coords == null) { + logger.error(String.format("Unable to place objective scenario on track %s," + + " as all coords were occupied. Aborting.", + trackState.getDisplayableName())); + return null; + } + + // create scenario - don't assign a force yet + StratconScenario scenario = StratconRulesManager.generateScenario(campaign, contract, + trackState, FORCE_NONE, coords, template); + + if (scenario == null) { + return null; + } + + // clear dates, because we don't want the scenario disappearing on us + scenario.setDeploymentDate(null); + scenario.setActionDate(null); + scenario.setReturnDate(null); + scenario.setStrategicObjective(true); + scenario.getBackingScenario().setCloaked(true); + + trackState.addScenario(scenario); + + return scenario; + } + + /** + * Fetches a random {@link StratconTrackState} from the {@link StratconCampaignState}. + * If no tracks are present, it logs an error message and returns {@code null}. + * + * @param contract The {@link AtBContract} from which the track state will be fetched. + * @return The randomly chosen {@link StratconTrackState}, or {@code null} if no tracks are available. + */ + public static @Nullable StratconTrackState getRandomTrack(AtBContract contract) { + List tracks = contract.getStratconCampaignState().getTracks(); + Random rand = new Random(); + + if (!tracks.isEmpty()) { + return tracks.get(rand.nextInt(tracks.size())); + } else { + logger.error("No tracks available. Unable to fetch random track"); + return null; + } } /** @@ -291,7 +411,7 @@ private static void finalizeBackingScenario(Campaign campaign, AtBContract contr StratconScenario scenario) { AtBDynamicScenarioFactory.finalizeScenario(scenario.getBackingScenario(), contract, campaign); setScenarioParametersFromBiome(track, scenario); - swapInPlayerUnits(scenario, campaign, Force.FORCE_NONE); + swapInPlayerUnits(scenario, campaign, FORCE_NONE); if (!autoAssignLances && !scenario.ignoreForceAutoAssignment()) { for (int forceID : scenario.getPlayerTemplateForceIDs()) { @@ -399,7 +519,7 @@ private static void swapInPlayerUnits(StratconScenario scenario, Campaign campai Collection potentialUnits = new HashSet<>(); // find units in player's campaign by default, all units in the TO&E are eligible - if (explicitForceID == Force.FORCE_NONE) { + if (explicitForceID == FORCE_NONE) { for (UUID unitId : campaign.getForces().getUnits()) { try { potentialUnits.add(campaign.getUnit(unitId)); @@ -457,7 +577,7 @@ private static void swapInPlayerUnits(StratconScenario scenario, Campaign campai * @param track The relevant StratCon track. * @return The newly generated {@link StratconScenario}. */ - public static StratconScenario generateScenarioForExistingForces(StratconCoords scenarioCoords, + public static @Nullable StratconScenario generateScenarioForExistingForces(StratconCoords scenarioCoords, Set forceIDs, AtBContract contract, Campaign campaign, StratconTrackState track) { return generateScenarioForExistingForces(scenarioCoords, forceIDs, contract, campaign, @@ -478,7 +598,7 @@ public static StratconScenario generateScenarioForExistingForces(StratconCoords * select a random template. * @return The newly generated {@link StratconScenario}. */ - public static StratconScenario generateScenarioForExistingForces(StratconCoords scenarioCoords, + public static @Nullable StratconScenario generateScenarioForExistingForces(StratconCoords scenarioCoords, Set forceIDs, AtBContract contract, Campaign campaign, StratconTrackState track, @Nullable ScenarioTemplate template) { boolean firstForce = true; @@ -488,6 +608,10 @@ public static StratconScenario generateScenarioForExistingForces(StratconCoords if (firstForce) { scenario = setupScenario(scenarioCoords, forceID, campaign, contract, track, template); firstForce = false; + + if (scenario == null) { + return null; + } } else { scenario.incrementRequiredPlayerLances(); scenario.addPrimaryForce(forceID); @@ -533,22 +657,14 @@ public static void deployForceToCoords(StratconCoords coords, int forceID, Campa // don't create a scenario on top of allied facilities StratconFacility facility = track.getFacility(coords); boolean isNonAlliedFacility = (facility != null) && (facility.getOwner() != ForceAlignment.Allied); - int targetNum = calculateScenarioOdds(track, contract, true); - boolean spawnScenario = (facility == null) && (Compute.randomInt(100) <= targetNum); - if (isNonAlliedFacility || spawnScenario) { + if (isNonAlliedFacility) { StratconScenario scenario = setupScenario(coords, forceID, campaign, contract, track); // we deploy immediately in this case, since we deployed the force manually setScenarioDates(0, track, campaign, scenario); AtBDynamicScenarioFactory.finalizeScenario(scenario.getBackingScenario(), contract, campaign); setScenarioParametersFromBiome(track, scenario); - // if we wound up with a field scenario, we may sub in dropships carrying - // units of the force in question - if (spawnScenario && !isNonAlliedFacility) { - swapInPlayerUnits(scenario, campaign, forceID); - } - commitPrimaryForces(campaign, scenario, track); } } @@ -563,7 +679,7 @@ public static void deployForceToCoords(StratconCoords coords, int forceID, Campa * @param track The relevant StratCon track. * @return The newly set up {@link StratconScenario}. */ - private static StratconScenario setupScenario(StratconCoords coords, int forceID, Campaign campaign, + private static @Nullable StratconScenario setupScenario(StratconCoords coords, int forceID, Campaign campaign, AtBContract contract, StratconTrackState track) { return setupScenario(coords, forceID, campaign, contract, track, null); } @@ -586,8 +702,9 @@ private static StratconScenario setupScenario(StratconCoords coords, int forceID * {@code null} to select the scenario template randomly. * @return The newly set up {@link StratconScenario}. */ - private static StratconScenario setupScenario(StratconCoords coords, int forceID, Campaign campaign, - AtBContract contract, StratconTrackState track, @Nullable ScenarioTemplate template) { + private static @Nullable StratconScenario setupScenario(StratconCoords coords, int forceID, Campaign campaign, + AtBContract contract, StratconTrackState track, + @Nullable ScenarioTemplate template) { StratconScenario scenario; if (track.getFacilities().containsKey(coords)) { @@ -603,6 +720,10 @@ private static StratconScenario setupScenario(StratconCoords coords, int forceID scenario = generateScenario(campaign, contract, track, forceID, coords); } + if (scenario == null) { + return null; + } + // we may generate a facility scenario randomly - if so, do the facility-related // stuff and add a new facility to the track if (scenario.getBackingScenario().getTemplate().isFacilityScenario()) { @@ -911,10 +1032,17 @@ public static void commitPrimaryForces(Campaign campaign, StratconScenario scena * is on defence */ private static boolean commanderLanceHasDefensiveAssignment(AtBDynamicScenario scenario, Campaign campaign) { - Unit commanderUnit = scenario.getLanceCommander(campaign).getUnit(); - Lance lance = campaign.getLances().get(commanderUnit.getForceId()); + Person lanceCommander = scenario.getLanceCommander(campaign); + if (lanceCommander != null){ + Unit commanderUnit = lanceCommander.getUnit(); + if (commanderUnit != null) { + Lance lance = campaign.getLances().get(commanderUnit.getForceId()); - return (lance != null) && lance.getRole().isDefence(); + return (lance != null) && lance.getRole().isDefence(); + } + } + + return false; } /** @@ -960,7 +1088,7 @@ private static Map> sortForcesByMapType(List * given force, on the * given track. Also registers it with the track and campaign. */ - private static StratconScenario generateScenario(Campaign campaign, AtBContract contract, StratconTrackState track, + private static @Nullable StratconScenario generateScenario(Campaign campaign, AtBContract contract, StratconTrackState track, int forceID, StratconCoords coords) { int unitType = campaign.getForce(forceID).getPrimaryUnitType(campaign); ScenarioTemplate template = StratconScenarioFactory.getRandomScenario(unitType); @@ -976,10 +1104,27 @@ private static StratconScenario generateScenario(Campaign campaign, AtBContract * given force, on the * given track, using the given template. Also registers it with the campaign. */ - static StratconScenario generateScenario(Campaign campaign, AtBContract contract, StratconTrackState track, + static @Nullable StratconScenario generateScenario(Campaign campaign, AtBContract contract, StratconTrackState track, int forceID, StratconCoords coords, ScenarioTemplate template) { StratconScenario scenario = new StratconScenario(); + if (template == null) { + int unitType = UnitType.MEK; + + try { + unitType = campaign.getForce(forceID).getPrimaryUnitType(campaign); + } catch (NullPointerException ignored) { + // This just means the player has no units + } + + template = StratconScenarioFactory.getRandomScenario(unitType); + } + + if (template == null) { + logger.error("Failed to fetch random scenario template. Aborting scenario generation."); + return null; + } + AtBDynamicScenario backingScenario = AtBDynamicScenarioFactory.initializeScenarioFromTemplate(template, contract, campaign); scenario.setBackingScenario(backingScenario); @@ -1011,10 +1156,10 @@ static StratconScenario generateScenario(Campaign campaign, AtBContract contract // dates, otherwise, the report messages for new scenarios look weird // also, suppress the "new scenario" report if not generating a scenario // for a specific force, as this indicates a contract initialization - campaign.addScenario(backingScenario, contract, forceID == Force.FORCE_NONE); + campaign.addScenario(backingScenario, contract, forceID == FORCE_NONE); scenario.setBackingScenarioID(backingScenario.getId()); - if (forceID > Force.FORCE_NONE) { + if (forceID > FORCE_NONE) { scenario.addPrimaryForce(forceID); } @@ -1252,7 +1397,6 @@ public static boolean forceCompositionMatchesDeclaredUnitType(int primaryUnitTyp * @return List of available force IDs. */ public static List getAvailableForceIDs(Campaign campaign) { - // first, we gather a set of all forces that are already deployed to a track so // we eliminate those later Set forcesInTracks = campaign.getActiveAtBContracts().stream() @@ -1566,49 +1710,37 @@ public static boolean canManuallyDeployAnyForce(StratconCoords coords, * figure out the odds of a scenario occurring. */ public static int calculateScenarioOdds(StratconTrackState track, AtBContract contract, - boolean playerDeployingForce) { - // rules: - // ROUTED: 0% - // CRITICAL: -10% when deploying forces to track, 0% attack - // WEAKENED: -5% - // ADVANCING: +5% - // DOMINATING: +10% - // OVERWHELMING: +100% - int moraleModifier = 0; - - switch (contract.getMoraleLevel()) { - case ROUTED: - return 0; - case CRITICAL: - if (playerDeployingForce) { - moraleModifier = -10; + boolean isReinforcements) { + if (contract.getMoraleLevel().isRouted()) { + return 0; + } + + int moraleModifier = switch (contract.getMoraleLevel()) { + case CRITICAL -> { + if (isReinforcements) { + yield -10; } else { - return 0; + yield 0; } - break; - case WEAKENED: - moraleModifier = -5; - break; - case ADVANCING: - moraleModifier = 5; - break; - case DOMINATING: - if (playerDeployingForce) { - moraleModifier = 20; + } + case WEAKENED -> -5; + case ADVANCING -> 5; + case DOMINATING -> { + if (isReinforcements) { + yield 20; } else { - return 10; + yield 10; } - break; - case OVERWHELMING: - if (playerDeployingForce) { - moraleModifier = 50; + } + case OVERWHELMING -> { + if (isReinforcements) { + yield 50; } else { - return 25; + yield 25; } - break; - default: - break; - } + } + default -> 0; + }; int dataCenterModifier = track.getScenarioOddsAdjustment(); @@ -1664,34 +1796,34 @@ public static void updateFacilityForScenario(AtBScenario scenario, AtBContract c * ResolveScenarioTracker.finish() * has been invoked. */ - public static void processScenarioCompletion(ResolveScenarioTracker rst) { - if (rst.getMission() instanceof AtBContract) { - StratconCampaignState campaignState = ((AtBContract) rst.getMission()).getStratconCampaignState(); + public static void processScenarioCompletion(ResolveScenarioTracker tracker) { + Campaign campaign = tracker.getCampaign(); + Mission mission = tracker.getMission(); + + if (mission instanceof AtBContract) { + StratconCampaignState campaignState = ((AtBContract) mission).getStratconCampaignState(); if (campaignState == null) { return; } + Scenario backingScenario = tracker.getScenario(); + + boolean victory = backingScenario.getStatus().isOverallVictory(); + for (StratconTrackState track : campaignState.getTracks()) { - if (track.getBackingScenariosMap().containsKey(rst.getScenario().getId())) { + if (track.getBackingScenariosMap().containsKey(backingScenario.getId())) { // things that may potentially happen: // scenario is removed from track - implemented // track gets remaining forces added to reinforcement pool // facility gets remaining forces stored in reinforcement pool // process VP and SO - StratconScenario scenario = track.getBackingScenariosMap().get(rst.getScenario().getId()); + StratconScenario scenario = track.getBackingScenariosMap().get(backingScenario.getId()); StratconFacility facility = track.getFacility(scenario.getCoords()); - boolean victory = rst.getScenario().getStatus().isOverallVictory(); - boolean draw = rst.getScenario().getStatus().isDraw(); - - if (scenario.isRequiredScenario()) { - if (draw) { - // do nothing - } else { - campaignState.updateVictoryPoints(victory ? 1 : -1); - } + if (scenario.isRequiredScenario() && !backingScenario.getStatus().isDraw()) { + campaignState.updateVictoryPoints(victory ? 1 : -1); } // this must be done before removing the scenario from the track @@ -1702,7 +1834,7 @@ public static void processScenarioCompletion(ResolveScenarioTracker rst) { switchFacilityOwner(facility); } - processTrackForceReturnDates(track, rst.getCampaign()); + processTrackForceReturnDates(track, campaign); track.removeScenario(scenario); break; @@ -1888,6 +2020,72 @@ public static boolean processIgnoredScenario(StratconScenario scenario, Stratcon return true; } + /** + * Performs the daily movement processing for each active scenario in every track of the given + * {@link StratconCampaignState}. + * This processing involves evaluating each scenario and, if it is not yet deployed, attempting + * to move it to an unoccupied coordinate. + * If movement is possible, the scenario is updated with the new coordinates and parameters are + * set based on the new location's biome. + * If the new location contains a facility, the scenario is replaced with a facility scenario. + * + * @param campaign the current campaign + * @param campaignState the relevant {@link StratconCampaignState} + */ + public static void processDailyMovement(Campaign campaign, StratconCampaignState campaignState) { + for (StratconTrackState track : campaignState.getTracks()) { + List allScenarios = new ArrayList<>(track.getScenarios().values()); + + for (StratconScenario scenario : allScenarios) { + if (scenario.getDeploymentDate() == null && !scenario.isStrategicObjective()) { + StratconCoords scenarioCoords = scenario.getCoords(); + StratconCoords newCoords = getUnoccupiedAdjacentCoords(scenarioCoords, track, true); + + if (newCoords == null) { + continue; + } + + track.removeScenario(scenario); + track.updateScenario(scenario); + + if (track.getFacility(newCoords) == null) { + scenario.setCoords(newCoords); + setScenarioParametersFromBiome(track, scenario); + track.addScenario(scenario); + } else { + generateExternalScenario(campaign, campaignState.getContract(), track, newCoords, + null, true); + } + } + } + } + } + + /** + * Processes a mass rout in the given campaign state. + *

+ * Loops through all tracks in the campaign state. + * For each track, it retrieves all scenarios. + * If scenario's deployment date is {@code null} and scenario is not a strategic objective, + * it is removed from the track and then updated. + * + * @param campaignState the relevant StratCon campaign state. + * @param removeAll whether to remove all scenarios, including those with dates or + * strategic objectives. This should be used sparingly. + */ + public static void processMassRout(StratconCampaignState campaignState, boolean removeAll) { + for (StratconTrackState track : campaignState.getTracks()) { + List allScenarios = new ArrayList<>(track.getScenarios().values()); + + for (StratconScenario scenario : allScenarios) { + if (removeAll || (scenario.getDeploymentDate() == null && !scenario.isStrategicObjective())) { + track.removeScenario(scenario); + track.updateScenario(scenario); + } + } + } + } + public void startup() { MekHQ.registerHandler(this); } @@ -1931,9 +2129,21 @@ public void handleNewDay(NewDayEvent ev) { } } - // on monday, generate new scenarios + processDailyMovement(ev.getCampaign(), campaignState); + + // on monday, generate new scenarios and reinforce existing enemy forces if (isMonday) { generateScenariosForTrack(ev.getCampaign(), contract, track); + + int reinforcementOdds = calculateScenarioOdds(track, contract, true); + + int roll = Compute.randomInt(100); + while (roll < reinforcementOdds) { + addHiddenExternalScenario(ev.getCampaign(), contract, track, null, false); + + reinforcementOdds -= roll; + roll = Compute.randomInt(100); + } } } } diff --git a/MekHQ/src/mekhq/campaign/stratcon/StratconTrackState.java b/MekHQ/src/mekhq/campaign/stratcon/StratconTrackState.java index cdfa08cf01..322bea7a4f 100644 --- a/MekHQ/src/mekhq/campaign/stratcon/StratconTrackState.java +++ b/MekHQ/src/mekhq/campaign/stratcon/StratconTrackState.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2022 - The MegaMek Team. All Rights Reserved. + * Copyright (c) 2020-2024 - The MegaMek Team. All Rights Reserved. * * This file is part of MekHQ. * @@ -23,9 +23,9 @@ import jakarta.xml.bind.annotation.XmlRootElement; import jakarta.xml.bind.annotation.XmlTransient; import megamek.common.annotations.Nullable; -import mekhq.utilities.MHQXMLUtility; import mekhq.campaign.mission.ScenarioForceTemplate.ForceAlignment; import mekhq.campaign.stratcon.StratconContractDefinition.StrategicObjectiveType; +import mekhq.utilities.MHQXMLUtility; import java.time.LocalDate; import java.util.*; @@ -107,6 +107,13 @@ public void setHeight(int height) { this.height = height; } + /** + * @return The size of the track derived by multiplying width and height. + */ + public int getSize() { + return width * height; + } + @XmlElementWrapper(name = "trackFacilities") @XmlElement(name = "facility") public Map getFacilities() { @@ -159,7 +166,7 @@ public void removeScenario(int campaignScenarioID) { removeScenario(getBackingScenariosMap().get(campaignScenarioID)); } } - + /** * Removes a StratconScenario from this track. */ @@ -169,7 +176,7 @@ public void removeScenario(StratconScenario scenario) { Map objectives = getObjectivesByCoords(); if (objectives.containsKey(scenario.getCoords())) { StrategicObjectiveType objectiveType = objectives.get(scenario.getCoords()).getObjectiveType(); - + switch (objectiveType) { case RequiredScenarioVictory: case SpecificScenarioVictory: diff --git a/MekHQ/src/mekhq/gui/dialog/ContractMarketDialog.java b/MekHQ/src/mekhq/gui/dialog/ContractMarketDialog.java index a9d580796f..2edd56216f 100644 --- a/MekHQ/src/mekhq/gui/dialog/ContractMarketDialog.java +++ b/MekHQ/src/mekhq/gui/dialog/ContractMarketDialog.java @@ -482,8 +482,6 @@ private void acceptContract(ActionEvent evt) { } } - contractStartPrompt(campaign, selectedContract); - selectedContract.setName(contractView.getContractName()); campaign.getFinances().credit(TransactionType.CONTRACT_PAYMENT, campaign.getLocalDate(), selectedContract.getTotalAdvanceAmount(), @@ -491,9 +489,11 @@ private void acceptContract(ActionEvent evt) { campaign.addMission(selectedContract); // must be invoked after campaign.addMission to ensure presence of mission ID selectedContract.acceptContract(campaign); + contractStartPrompt(campaign, selectedContract); + contractMarket.removeContract(selectedContract); ((DefaultTableModel) tableContracts.getModel()).removeRow(tableContracts - .convertRowIndexToModel(tableContracts.getSelectedRow())); + .convertRowIndexToModel(tableContracts.getSelectedRow())); refreshContractView(); } } @@ -529,6 +529,10 @@ private boolean triggerConfirmationDialog() { resourceKey = "messageChallengeHard.text"; } + if (((AtBContract) selectedContract).getContractType().isGarrisonDuty()) { + resourceKey = "messageChallengeGarrison.text"; + } + // If resourceKey is not found, just return true, acting as if the player had accepted the mission if (resourceKey.isEmpty()) { return true; diff --git a/MekHQ/src/mekhq/gui/stratcon/CampaignManagementDialog.java b/MekHQ/src/mekhq/gui/stratcon/CampaignManagementDialog.java index 89522fe938..b10de2352f 100644 --- a/MekHQ/src/mekhq/gui/stratcon/CampaignManagementDialog.java +++ b/MekHQ/src/mekhq/gui/stratcon/CampaignManagementDialog.java @@ -1,16 +1,21 @@ /* -* MegaMek - Copyright (C) 2021 - The MegaMek Team -* -* This program is free software; you can redistribute it and/or modify it under -* the terms of the GNU General Public License as published by the Free Software -* Foundation; either version 2 of the License, or (at your option) any later -* version. -* -* This program 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. -*/ + * Copyright (c) 2021-2024 - 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 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ package mekhq.gui.stratcon; @@ -55,8 +60,9 @@ public void display(StratconCampaignState campaignState, StratconTrackState curr lblTrackScenarioOdds.setVisible(gmMode); if (gmMode) { - lblTrackScenarioOdds.setText(String.format("Track Scenario Odds: %d%%", - StratconRulesManager.calculateScenarioOdds(currentTrack, campaignState.getContract(), false))); + lblTrackScenarioOdds.setText(String.format("Track Reinforcement Odds: %d%%", + StratconRulesManager.calculateScenarioOdds(currentTrack, campaignState.getContract(), + true))); } }