Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions MekHQ/src/mekhq/campaign/stratCon/StratConRulesManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ public static void finalizeBackingScenario(Campaign campaign, AtBContract contra
finalizeScenario(backingScenario, contract, campaign);
swapInPlayerUnits(scenario, campaign, FORMATION_NONE);

if (!autoAssignLances && !scenario.ignoreForceAutoAssignment()) {
if (!autoAssignLances && !scenario.overrideForceAutoAssignment()) {
for (int forceID : scenario.getPlayerTemplateForceIDs()) {
backingScenario.removeFormation(forceID);
}
Expand Down Expand Up @@ -1089,7 +1089,9 @@ private static boolean isValidUnitForScenario(Unit unit, ScenarioForceTemplate s
// this is theoretically possible if forceIDs is empty - not likely in practice
// but might as well, to future-proof.
if (scenario != null) {
scenario.setIgnoreForceAutoAssignment(true);
// Don't auto-assign forces for Official Challenge scenarios - the player should choose their force
boolean isOfficialChallenge = scenario.getBackingScenario().getStratConScenarioType().isOfficialChallenge();
scenario.setOverrideForceAutoAssignment(!isOfficialChallenge);
}

return scenario;
Expand Down Expand Up @@ -1155,16 +1157,18 @@ public static void deployForceToCoords(StratConCoords coords, int forceID, Campa

// we may stumble on a fixed objective scenario - in that case assign the force
// to it and finalize we also will not be encountering any of the other stuff so bug out
// afterward
// afterward. Official Challenge scenarios should not auto-assign forces.
StratConScenario revealedScenario = track.getScenario(coords);
if (revealedScenario != null) {
revealedScenario.addPrimaryForce(forceID);
commitPrimaryForces(campaign, revealedScenario, track);
if (!revealedScenario.getBackingScenario().isFinalized()) {
setScenarioParametersFromBiome(track,
revealedScenario,
campaign.getCampaignOptions().isUseNoTornadoes());
finalizeScenario(revealedScenario.getBackingScenario(), contract, campaign);
if (!revealedScenario.getBackingScenario().getStratConScenarioType().isOfficialChallenge()) {
revealedScenario.addPrimaryForce(forceID);
commitPrimaryForces(campaign, revealedScenario, track);
if (!revealedScenario.getBackingScenario().isFinalized()) {
setScenarioParametersFromBiome(track,
revealedScenario,
campaign.getCampaignOptions().isUseNoTornadoes());
finalizeScenario(revealedScenario.getBackingScenario(), contract, campaign);
}
}
return;
}
Expand Down
10 changes: 5 additions & 5 deletions MekHQ/src/mekhq/campaign/stratCon/StratConScenario.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public String getScenarioStateName() {
private LocalDate returnDate;
private StratConCoords coords;
private int numDefensivePoints;
private boolean ignoreForceAutoAssignment;
private boolean overrideForceAutoAssignment;
private int leadershipPointsUsed;
private Set<Integer> failedReinforcements = new HashSet<>();
private ArrayList<Integer> primaryForceIDs = new ArrayList<>();
Expand Down Expand Up @@ -485,12 +485,12 @@ public void removeFailedReinforcements(int forceID) {
failedReinforcements.remove(forceID);
}

public boolean ignoreForceAutoAssignment() {
return ignoreForceAutoAssignment;
public boolean overrideForceAutoAssignment() {
return overrideForceAutoAssignment;
}

public void setIgnoreForceAutoAssignment(boolean ignoreForceAutoAssignment) {
this.ignoreForceAutoAssignment = ignoreForceAutoAssignment;
public void setOverrideForceAutoAssignment(boolean overrideForceAutoAssignment) {
this.overrideForceAutoAssignment = overrideForceAutoAssignment;
}

public int getLeadershipPointsUsed() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/*
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*
* 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"
* <https://www.xbox.com/en-US/developers/rules> and it is not endorsed by or
* affiliated with Microsoft.
*/
package mekhq.campaign.stratCon;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.Vector;

import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;

import mekhq.campaign.Campaign;
import mekhq.campaign.Hangar;
import mekhq.campaign.campaignOptions.CampaignOptions;
import mekhq.campaign.force.CombatTeam;
import mekhq.campaign.force.Formation;
import mekhq.campaign.mission.AtBContract;
import mekhq.campaign.mission.AtBDynamicScenario;
import mekhq.campaign.mission.enums.CombatRole;
import mekhq.campaign.mission.enums.ScenarioType;
import mekhq.campaign.personnel.Person;
import mekhq.campaign.unit.Unit;

/**
* Tests for {@link StratConRulesManager}
*/
class StratConRulesManagerTest {

/**
* Creates the common mock infrastructure needed for deployForceToCoords tests.
* processForceDeployment -> scanNeighboringCoords touches many objects.
*/
private void setupProcessForceDeploymentMocks(Campaign campaign, CampaignOptions options,
StratConTrackState track, int forceID) {
// scanNeighboringCoords needs revealed coords set
when(track.getRevealedCoords()).thenReturn(new HashSet<>());
when(track.getScanRangeIncrease()).thenReturn(0);

// increaseFatigue needs Formation -> Units -> Crew
Formation formation = mock(Formation.class);
when(campaign.getFormation(forceID)).thenReturn(formation);

UUID unitId = UUID.randomUUID();
Vector<UUID> unitIds = new Vector<>();
unitIds.add(unitId);
when(formation.getAllUnits(false)).thenReturn(unitIds);

Unit unit = mock(Unit.class);
when(campaign.getUnit(unitId)).thenReturn(unit);
when(unit.getCrew()).thenReturn(List.of(mock(Person.class)));

// CampaignOptions needed by scanNeighboringCoords
when(options.isUseFatigue()).thenReturn(false);
when(options.getFatigueRate()).thenReturn(0);

// processForceDeployment needs LocalDate and Hangar
when(campaign.getLocalDate()).thenReturn(LocalDate.of(3025, 1, 15));
when(campaign.getHangar()).thenReturn(mock(Hangar.class));

// Track setup for processForceDeployment
when(track.getAssignedCoordForces()).thenReturn(new HashMap<>());
}

/**
* Verifies that when a force deploys to coordinates containing an Official Challenge scenario,
* the force is NOT auto-assigned to that scenario. This is a regression test for
* <a href="https://github.com/MegaMek/mekhq/issues/8612">issue #8612</a>.
*/
@Test
void deployForceToCoords_officialChallenge_doesNotAutoAssignForce() {
Campaign campaign = mock(Campaign.class);
CampaignOptions options = mock(CampaignOptions.class);
when(campaign.getCampaignOptions()).thenReturn(options);

AtBContract contract = mock(AtBContract.class);
StratConTrackState track = mock(StratConTrackState.class);
StratConCoords coords = new StratConCoords(2, 3);
int forceID = 1;

// Create an Official Challenge scenario at the target coords
StratConScenario challengeScenario = mock(StratConScenario.class);
AtBDynamicScenario backingScenario = mock(AtBDynamicScenario.class);
when(backingScenario.getStratConScenarioType()).thenReturn(ScenarioType.OFFICIAL_CHALLENGE);
when(backingScenario.isFinalized()).thenReturn(true);
when(backingScenario.isCloaked()).thenReturn(false);
when(challengeScenario.getBackingScenario()).thenReturn(backingScenario);
when(track.getScenario(coords)).thenReturn(challengeScenario);

// Setup combat team
CombatTeam combatTeam = mock(CombatTeam.class);
CombatRole combatRole = mock(CombatRole.class);
when(combatRole.isPatrol()).thenReturn(false);
when(combatRole.isTraining()).thenReturn(false);
when(combatTeam.getRole()).thenReturn(combatRole);
var combatTeamsMap = new Hashtable<Integer, CombatTeam>();
combatTeamsMap.put(forceID, combatTeam);
when(campaign.getCombatTeamsAsMap()).thenReturn(combatTeamsMap);

setupProcessForceDeploymentMocks(campaign, options, track, forceID);

// Act
StratConRulesManager.deployForceToCoords(coords, forceID, campaign, contract, track, false);

// Assert: force should NOT be added to Official Challenge scenario
verify(challengeScenario, never()).addPrimaryForce(anyInt());
}

/**
* Verifies that when a force deploys to coordinates containing a non-challenge scenario
* (e.g., a fixed objective), the force IS auto-assigned as before.
*/
@Test
void deployForceToCoords_nonChallengeScenario_autoAssignsForce() {
Campaign campaign = mock(Campaign.class);
CampaignOptions options = mock(CampaignOptions.class);
when(campaign.getCampaignOptions()).thenReturn(options);

AtBContract contract = mock(AtBContract.class);
StratConTrackState track = mock(StratConTrackState.class);
StratConCoords coords = new StratConCoords(2, 3);
int forceID = 1;

// Create a regular scenario at the target coords
StratConScenario regularScenario = mock(StratConScenario.class);
AtBDynamicScenario backingScenario = mock(AtBDynamicScenario.class);
when(backingScenario.getStratConScenarioType()).thenReturn(ScenarioType.NONE);
when(backingScenario.isFinalized()).thenReturn(true);
when(backingScenario.isCloaked()).thenReturn(false);
when(regularScenario.getBackingScenario()).thenReturn(backingScenario);
when(regularScenario.getPrimaryForceIDs()).thenReturn(new java.util.ArrayList<>());
when(regularScenario.getPlayerTemplateForceIDs()).thenReturn(new java.util.ArrayList<>());
when(track.getScenario(coords)).thenReturn(regularScenario);

// Setup combat team
CombatTeam combatTeam = mock(CombatTeam.class);
CombatRole combatRole = mock(CombatRole.class);
when(combatRole.isPatrol()).thenReturn(false);
when(combatRole.isTraining()).thenReturn(false);
when(combatTeam.getRole()).thenReturn(combatRole);
var combatTeamsMap = new Hashtable<Integer, CombatTeam>();
combatTeamsMap.put(forceID, combatTeam);
when(campaign.getCombatTeamsAsMap()).thenReturn(combatTeamsMap);

setupProcessForceDeploymentMocks(campaign, options, track, forceID);

// Act
StratConRulesManager.deployForceToCoords(coords, forceID, campaign, contract, track, false);

// Assert: force SHOULD be added to the regular scenario
verify(regularScenario).addPrimaryForce(forceID);
}

/**
* Verifies that when an Official Challenge scenario spawns on a hex that already has a deployed
* force (the {@code generateScenarioForExistingForces} path), the scenario does NOT override
* force auto-assignment. This means {@code finalizeBackingScenario} (called with
* {@code autoAssignLances=false} in {@code generateDailyScenariosForTrack}) will remove the
* forces from the backing scenario and set the scenario to UNRESOLVED, preventing
* auto-assignment.
*
* <p>Regression test for
* <a href="https://github.com/MegaMek/mekhq/issues/8612">issue #8612</a>
* — spawn-on-existing-force path.
*/
@Test
void generateScenarioForExistingForces_officialChallenge_doesNotOverrideAutoAssignment() {
Campaign campaign = mock(Campaign.class);
CampaignOptions options = mock(CampaignOptions.class);
when(campaign.getCampaignOptions()).thenReturn(options);
when(options.isUseStratConMaplessMode()).thenReturn(false);

AtBContract contract = mock(AtBContract.class);
StratConTrackState track = mock(StratConTrackState.class);
StratConCoords coords = new StratConCoords(2, 3);

// Create a mock scenario whose backing scenario is an Official Challenge
StratConScenario mockScenario = mock(StratConScenario.class);
AtBDynamicScenario backingScenario = mock(AtBDynamicScenario.class);
when(backingScenario.getStratConScenarioType()).thenReturn(ScenarioType.OFFICIAL_CHALLENGE);
when(mockScenario.getBackingScenario()).thenReturn(backingScenario);

Set<Integer> forceIDs = new LinkedHashSet<>(List.of(42));

try (MockedStatic<StratConRulesManager> mockedManager =
Mockito.mockStatic(StratConRulesManager.class, CALLS_REAL_METHODS)) {
// Mock setupScenario to return our controlled Official Challenge scenario
mockedManager.when(() -> StratConRulesManager.setupScenario(
any(), any(), any(), any(), any(), any(), anyBoolean(), any()
)).thenReturn(mockScenario);

// Act
StratConScenario result = StratConRulesManager.generateScenarioForExistingForces(
coords, forceIDs, contract, campaign, track, null, null);

// Assert
assertNotNull(result);
// overrideForceAutoAssignment must be false for Official Challenge,
// so finalizeBackingScenario will remove formations instead of committing them
verify(mockScenario).setOverrideForceAutoAssignment(false);
}
}

/**
* Verifies that when a non-challenge scenario spawns on a hex with an existing force,
* the scenario DOES override force auto-assignment (so forces are committed as usual).
*/
@Test
void generateScenarioForExistingForces_nonChallenge_overridesAutoAssignment() {
Campaign campaign = mock(Campaign.class);
CampaignOptions options = mock(CampaignOptions.class);
when(campaign.getCampaignOptions()).thenReturn(options);
when(options.isUseStratConMaplessMode()).thenReturn(false);

AtBContract contract = mock(AtBContract.class);
StratConTrackState track = mock(StratConTrackState.class);
StratConCoords coords = new StratConCoords(2, 3);

// Create a mock scenario whose backing scenario is NOT an Official Challenge
StratConScenario mockScenario = mock(StratConScenario.class);
AtBDynamicScenario backingScenario = mock(AtBDynamicScenario.class);
when(backingScenario.getStratConScenarioType()).thenReturn(ScenarioType.NONE);
when(mockScenario.getBackingScenario()).thenReturn(backingScenario);

Set<Integer> forceIDs = new LinkedHashSet<>(List.of(42));

try (MockedStatic<StratConRulesManager> mockedManager =
Mockito.mockStatic(StratConRulesManager.class, CALLS_REAL_METHODS)) {
mockedManager.when(() -> StratConRulesManager.setupScenario(
any(), any(), any(), any(), any(), any(), anyBoolean(), any()
)).thenReturn(mockScenario);

// Act
StratConScenario result = StratConRulesManager.generateScenarioForExistingForces(
coords, forceIDs, contract, campaign, track, null, null);

// Assert
assertNotNull(result);
// overrideForceAutoAssignment must be true for non-challenge scenarios,
// so finalizeBackingScenario will commit forces as normal
verify(mockScenario).setOverrideForceAutoAssignment(true);
}
}
}
Loading