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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
*/
package mekhq.campaign.personnel.generator;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
Expand All @@ -43,15 +44,21 @@
import megamek.common.options.IOption;
import megamek.common.options.OptionsConstants;
import megamek.common.units.Crew;
import megamek.logging.MMLogger;
import mekhq.campaign.Campaign;
import mekhq.campaign.personnel.Person;
import mekhq.campaign.personnel.PersonnelOptions;
import mekhq.campaign.personnel.SpecialAbility;
import mekhq.campaign.universe.Faction;
import mekhq.campaign.universe.eras.Era;
import mekhq.campaign.universe.eras.Eras;

/**
* Generates a single special ability for a {@link Person}.
*/
public class SingleSpecialAbilityGenerator extends AbstractSpecialAbilityGenerator {
private static final MMLogger LOGGER = MMLogger.create(SingleSpecialAbilityGenerator.class);

@Override
public boolean generateSpecialAbilities(final Campaign campaign, final Person person,
final int expLvl) {
Expand Down Expand Up @@ -97,6 +104,10 @@ public boolean generateSpecialAbilities(final Campaign campaign, final Person pe
useAlternativeWeighting,
ignoreEligibility,
isVeterancyAward);
if (person.isClanPersonnel()) {
filterOutMeleeSPA(campaign.getLocalDate(), person.getOriginFaction(), abilityList);
}

if (abilityList.isEmpty()) {
return null;
}
Expand Down Expand Up @@ -170,6 +181,43 @@ public boolean generateSpecialAbilities(final Campaign campaign, final Person pe
return displayName;
}

/**
* Filters out melee-related Special Personnel Abilities (SPAs) from the given list.
*
* <p>Melee SPAs ({@code "melee_specialist"} and {@code "melee_master"}) are removed if the origin faction is a
* Clan, and either:</p>
* <ul>
* <li>the faction is a Homeworld Clan, or</li>
* <li>the current date falls before the end of the Late Republic era ({@code "LREP"})</li>
* </ul>
*
* <p>The 'end of the Republic era' date was picked, as by the Dark Ages it appears the Clan dislike of melee
* has faded. At least among clans that had settled the Inner Sphere.</p>
*
* @param today the current date, used to determine era eligibility
* @param originFaction the faction whose rules govern SPA eligibility
* @param abilityList the mutable list of {@link SpecialAbility} objects to filter in-place
*
* @author Illiani
* @since 0.51.0
*/
private static void filterOutMeleeSPA(LocalDate today, Faction originFaction, List<SpecialAbility> abilityList) {
Era lateRepublicEra = Eras.getInstance().getEra("LREP");
if (lateRepublicEra == null) {
LOGGER.error("Late Republic era (LREP) not found in Eras.getInstance()");
return;
}

LocalDate endDate = lateRepublicEra.getEnd();

if (originFaction.isClan()) {
if (originFaction.isHomeworldClan() || today.isBefore(endDate)) {
abilityList.removeIf(spa -> spa.getName().equalsIgnoreCase("melee_specialist"));
abilityList.removeIf(spa -> spa.getName().equalsIgnoreCase("melee_master"));
}
}
}

/**
* Compiles and returns a list of {@link SpecialAbility} objects available to the given person, according to
* eligibility and weighting rules.
Expand Down
55 changes: 39 additions & 16 deletions MekHQ/src/mekhq/campaign/universe/Faction.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,34 +174,33 @@ public String getFullName(int year) {
}

/**
* Tests whether this faction is institutionally compatible with another faction via the
* {@code fallBackFactions} successor/predecessor data already populated in the YAML faction files.
* Tests whether this faction is institutionally compatible with another faction via the {@code fallBackFactions}
* successor/predecessor data already populated in the YAML faction files.
*
* <p>Used by faction-restricted academy access (and any future eligibility check) for non-FedCom
* faction successions: Clan Ghost Bear / Free Rasalhague Republic into Rasalhague Dominion, ComStar
* into Word of Blake, and similar mergers/splits. The check is bidirectional — either side's
* {@code fallBackFactions} can carry the relationship — because the YAMLs only declare the
* successor's predecessors (e.g. {@code RD.fallBackFactions = [CGB, FRR]}), never the inverse.
* faction successions: Clan Ghost Bear / Free Rasalhague Republic into Rasalhague Dominion, ComStar into Word of
* Blake, and similar mergers/splits. The check is bidirectional — either side's {@code fallBackFactions} can carry
* the relationship — because the YAMLs only declare the successor's predecessors (e.g.
* {@code RD.fallBackFactions = [CGB, FRR]}), never the inverse.
*
* <p>Meta-faction codes are excluded as compatibility <em>targets</em>: a real faction is never
* considered "compatible" with the abstract meta-faction {@code IS} (or {@code CLAN.IS}, or any
* {@code Periphery.*} / {@code CLAN.*} code) just because the real faction's
* {@code fallBackFactions} list includes that meta code as a generic data-lookup fallback. Most
* playable Inner Sphere factions list {@code IS} as a fallback for the
* {@link mekhq.campaign.universe.RandomFactionGenerator} machinery, so without this exclusion
* any of them would erroneously test compatible with the abstract IS umbrella.
* considered "compatible" with the abstract meta-faction {@code IS} (or {@code CLAN.IS}, or any {@code Periphery.*}
* / {@code CLAN.*} code) just because the real faction's {@code fallBackFactions} list includes that meta code as a
* generic data-lookup fallback. Most playable Inner Sphere factions list {@code IS} as a fallback for the
* {@link mekhq.campaign.universe.RandomFactionGenerator} machinery, so without this exclusion any of them would
* erroneously test compatible with the abstract IS umbrella.
*
* <p>The exclusion does <em>not</em> change comparisons between two real factions: LA and FS, for
* example, are correctly considered incompatible by this method because neither lists the other's
* short code in its fallbacks, regardless of any shared meta entries.
* example, are correctly considered incompatible by this method because neither lists the other's short code in its
* fallbacks, regardless of any shared meta entries.
*
* <p>FedCom-specific era rules (LA seceding 3057, Yvonne reverting to Federated Suns 3067) are
* handled separately at the call site; this method intentionally does not look at the date.
*
* @param other the other faction to test compatibility against
*
* @return {@code true} if this and {@code other} are the same faction, or if either lists the
* other in its {@code fallBackFactions} (after meta-code exclusion); {@code false} otherwise
* @return {@code true} if this and {@code other} are the same faction, or if either lists the other in its
* {@code fallBackFactions} (after meta-code exclusion); {@code false} otherwise
*/
public boolean isLineageCompatible(final @Nullable Faction other) {
if (other == null) {
Expand Down Expand Up @@ -717,4 +716,28 @@ public boolean isAggregate() {
public @Nullable FactionLeaderData getLeaderForYear(final int year) {
return faction2 != null ? faction2.getFactionLeaderForYear(year) : null;
}

/**
* Determines whether this faction is a Homeworld Clan.
*
* <p>A faction qualifies as a Homeworld Clan if it is a Clan faction and one of its alternative faction codes
* matches {@code "Clan.HW"} (case-insensitive).
*
* @return {@code true} if this faction is a Clan and has {@code "Clan.HW"} among its alternative faction codes;
* {@code false} otherwise
*
* @author Illiani
* @since 0.51.0
*/
public boolean isHomeworldClan() {
if (isClan()) {
for (String factionCode : alternativeFactionCodes) {
if (factionCode.equalsIgnoreCase("Clan.HW")) {
return true;
}
}
}

return false;
}
}
20 changes: 20 additions & 0 deletions MekHQ/src/mekhq/campaign/universe/eras/Eras.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,26 @@ private void setEras(final TreeMap<LocalDate, Era> eras) {
public Era getEra(final LocalDate today) {
return getEras().ceilingEntry(today).getValue();
}

/**
* Retrieves an {@link Era} by its code, using a case-insensitive match.
*
* @param code the era code to search for (e.g., {@code "LREP"})
*
* @return the matching {@link Era}, or {@code null} if no era with the given code exists
*
* @author Illiani
* @since 0.51.0
*/
public @Nullable Era getEra(final String code) {
for (final Era era : getEras().values()) {
if (era.getCode().equalsIgnoreCase(code)) {
return era;
}
}

return null;
}
//endregion Getters/Setters

//region File I/O
Expand Down