diff --git a/MekHQ/src/mekhq/campaign/personnel/generator/SingleSpecialAbilityGenerator.java b/MekHQ/src/mekhq/campaign/personnel/generator/SingleSpecialAbilityGenerator.java index 191da1d79c8..5c293e958a3 100644 --- a/MekHQ/src/mekhq/campaign/personnel/generator/SingleSpecialAbilityGenerator.java +++ b/MekHQ/src/mekhq/campaign/personnel/generator/SingleSpecialAbilityGenerator.java @@ -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; @@ -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) { @@ -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; } @@ -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. + * + *

Melee SPAs ({@code "melee_specialist"} and {@code "melee_master"}) are removed if the origin faction is a + * Clan, and either:

+ * + * + *

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.

+ * + * @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 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. diff --git a/MekHQ/src/mekhq/campaign/universe/Faction.java b/MekHQ/src/mekhq/campaign/universe/Faction.java index eab0aa3ea5d..5c288f346fa 100644 --- a/MekHQ/src/mekhq/campaign/universe/Faction.java +++ b/MekHQ/src/mekhq/campaign/universe/Faction.java @@ -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. * *

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. * *

Meta-faction codes are excluded as compatibility targets: 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. * *

The exclusion does not 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. * *

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) { @@ -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. + * + *

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; + } } diff --git a/MekHQ/src/mekhq/campaign/universe/eras/Eras.java b/MekHQ/src/mekhq/campaign/universe/eras/Eras.java index 381611bf6d0..33720c7b192 100644 --- a/MekHQ/src/mekhq/campaign/universe/eras/Eras.java +++ b/MekHQ/src/mekhq/campaign/universe/eras/Eras.java @@ -85,6 +85,26 @@ private void setEras(final TreeMap 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