diff --git a/forge-ai/src/main/java/forge/ai/SpellAbilityAi.java b/forge-ai/src/main/java/forge/ai/SpellAbilityAi.java index d2af9e0d1ce6..5677b47dfcd8 100644 --- a/forge-ai/src/main/java/forge/ai/SpellAbilityAi.java +++ b/forge-ai/src/main/java/forge/ai/SpellAbilityAi.java @@ -11,6 +11,7 @@ import forge.card.ICardFace; import forge.card.mana.ManaCost; import forge.game.GameEntity; +import forge.game.ability.IHasForgeParams; import forge.game.card.Card; import forge.game.card.CardCopyService; import forge.game.card.CardState; @@ -36,7 +37,12 @@ *

* The three main methods are canPlayAI(), chkAIDrawback and doTriggerAINoCost. */ -public abstract class SpellAbilityAi { +public abstract class SpellAbilityAi implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "AIActivateLast", "AIBidMax", "AICheckSVar", "AILifeThreshold", "AILogic", + "AIManaPref", "AIMaxTgtsCount", "AIPhyrexianPayment", "AIRespondsToOwnAbility", + "AISVarCompare", "AITgts", "AITgtsStrict", "AIXMax", "UnlessAI", + }; public Predicate CREATURE_OR_TAP_ABILITY = c -> { if (c.isCreature()) { diff --git a/forge-game/src/main/java/forge/game/CardTraitBase.java b/forge-game/src/main/java/forge/game/CardTraitBase.java index 2bf2caed7e9b..d6ce13851a92 100644 --- a/forge-game/src/main/java/forge/game/CardTraitBase.java +++ b/forge-game/src/main/java/forge/game/CardTraitBase.java @@ -15,6 +15,7 @@ import forge.card.MagicColor; import forge.card.mana.ManaAtom; import forge.game.ability.AbilityUtils; +import forge.game.ability.IHasForgeParams; import forge.game.card.Card; import forge.game.card.CardCollection; import forge.game.card.CardLists; @@ -36,7 +37,17 @@ * Base class for Triggers,ReplacementEffects and StaticAbilities. * */ -public abstract class CardTraitBase implements GameObject, IHasCardView, IHasSVars { +public abstract class CardTraitBase implements GameObject, IHasCardView, IHasSVars, IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "Adamant", "Blessing", "Bloodthirst", "CheckDefinedPlayer", "CheckSVar", + "CheckSecondSVar", "ClassLevel", "DayTime", "DefinedPlayerCompare", "Delirium", + "Desert", "FatefulHour", "Hellbent", "Invert", "IsPresent", "IsPresent2", + "LifeAmount", "LifeTotal", "ManaNotSpent", "ManaSpent", "Metalcraft", "Monarch", + "PresentCompare", "PresentCompare2", "PresentDefined", "PresentPlayer", + "PresentPlayer2", "PresentZone", "PresentZone2", "Revolt", "SVarCompare", + "SecondSVarCompare", "Secondary", "Threshold", "WerewolfTransformCondition", + "WerewolfUntransformCondition", + }; /** The host card. */ protected Card hostCard; diff --git a/forge-game/src/main/java/forge/game/ability/AbilityFactory.java b/forge-game/src/main/java/forge/game/ability/AbilityFactory.java index 978fa0db4af7..50a2d6bde31f 100644 --- a/forge-game/src/main/java/forge/game/ability/AbilityFactory.java +++ b/forge-game/src/main/java/forge/game/ability/AbilityFactory.java @@ -45,7 +45,18 @@ * @author Forge * @version $Id$ */ -public final class AbilityFactory { +public final class AbilityFactory implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "BidSubAbility", "CantChooseSubAbility", "Choices", "ChooseNumberSubAbility", + "ChooseSubAbility", "ChosenPile", "Cost", "Execute", "FallbackAbility", + "FalseSubAbility", "GiftAbility", "GuessCorrect", "GuessWrong", "HeadsSubAbility", + "Highest", "LoseSubAbility", "Lowest", "MatchedAbility", "NonBasicSpell", + "NotLowest", "Origin", "OtherwiseSubAbility", "PreventionSubAbility", + "RegenerationAbility", "RepeatSubAbility", "ResultSubAbilities", "ReturnAbility", + "SpellDescription", "SubAbility", "TailsSubAbility", "TrueSubAbility", + "UnchosenPile", "UnmatchedAbility", "ValidTgts", "VoteSubAbility", + "VoteTiedAbility", "WinSubAbility", + }; public static final List additionalAbilityKeys = Lists.newArrayList( "WinSubAbility", "OtherwiseSubAbility", // Clash diff --git a/forge-game/src/main/java/forge/game/ability/AbilityUtils.java b/forge-game/src/main/java/forge/game/ability/AbilityUtils.java index 780e3afb037b..dfbc01b4efe6 100644 --- a/forge-game/src/main/java/forge/game/ability/AbilityUtils.java +++ b/forge-game/src/main/java/forge/game/ability/AbilityUtils.java @@ -49,7 +49,14 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -public class AbilityUtils { +public class AbilityUtils implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "AbilityCount", "AnnounceMax", "Destination", "ETB", "ForgetOtherTargets", + "IncludeAllComponentCards", "LockInText", "RememberCostMana", "RememberTargets", + "Triggered", "UnlessColor", "UnlessCost", "UnlessPayer", "UnlessResolveSubs", + "UnlessSwitched", "UnlessUpTo", "XMax", "XMin", + }; + private final static ImmutableList cmpList = ImmutableList.of("LT", "LE", "EQ", "GE", "GT", "NE"); // should the three getDefined functions be merged into one? Or better to diff --git a/forge-game/src/main/java/forge/game/ability/IHasForgeParams.java b/forge-game/src/main/java/forge/game/ability/IHasForgeParams.java new file mode 100644 index 000000000000..0ac7051a9281 --- /dev/null +++ b/forge-game/src/main/java/forge/game/ability/IHasForgeParams.java @@ -0,0 +1,31 @@ +package forge.game.ability; + +/** + * Implemented by classes that declare the card-script parameters they consume: the optional params + * in a {@code public static final String[] OPTIONAL_PARAMS} field, and (for effects) mutually-required + * groups in a {@code String[][] REQUIRED_PARAMS} field. The accessors expose those fields so the + * card-script linter can ask a class which params it accepts; CardScriptParamDeclarationTest discovers + * implementors by classpath scan and guards the declarations against drift. + * + * The accessors read the field of the runtime class ({@code getClass()}) rather than returning a field + * directly. Because the fields are {@code static}, a direct {@code return OPTIONAL_PARAMS} inherited by a + * subclass would return the superclass's field; reading {@code getClass()}'s field returns the subclass's + * own declaration (or the inherited one when it declares none), so no per-class override is needed. + */ +public interface IHasForgeParams { + default String[] getOptionalParams() { + try { + return (String[]) getClass().getField("OPTIONAL_PARAMS").get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + return new String[0]; + } + } + + default String[][] getRequiredParams() { + try { + return (String[][]) getClass().getField("REQUIRED_PARAMS").get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + return new String[0][]; + } + } +} diff --git a/forge-game/src/main/java/forge/game/ability/SpellAbilityEffect.java b/forge-game/src/main/java/forge/game/ability/SpellAbilityEffect.java index a364a67c8783..198fd3778b7a 100644 --- a/forge-game/src/main/java/forge/game/ability/SpellAbilityEffect.java +++ b/forge-game/src/main/java/forge/game/ability/SpellAbilityEffect.java @@ -40,7 +40,16 @@ * @version $Id: AbilityFactoryAlterLife.java 17656 2012-10-22 19:32:56Z Max mtg $ */ -public abstract class SpellAbilityEffect { +public abstract class SpellAbilityEffect implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "AfterDescription", "Amount", "Announce", "AtEOTCondition", "AtEOTDesc", + "ConditionDescription", "Defined", "DefinedExiler", "Duration", + "ExiledWithEffectSource", "ForEach", "Forecast", "GiftDescription", + "IncludeAllComponentCards", "Named", "ReplaceDyingCondition", "ReplaceDyingDefined", + "ReplaceDyingExiledWith", "ReplaceDyingValid", "ReplaceDyingZone", "ReturnAbility", + "ReturnValid", "SpellDescription", "StackDescription", "StartingWith", + "ThisDefinedAndTgts", + }; public abstract void resolve(SpellAbility sa); diff --git a/forge-game/src/main/java/forge/game/cost/Cost.java b/forge-game/src/main/java/forge/game/cost/Cost.java index b138995abd01..3ed99d20ca91 100644 --- a/forge-game/src/main/java/forge/game/cost/Cost.java +++ b/forge-game/src/main/java/forge/game/cost/Cost.java @@ -21,6 +21,7 @@ import forge.card.CardType; import forge.card.mana.ManaCost; import forge.game.CardTraitBase; +import forge.game.ability.IHasForgeParams; import forge.game.card.Card; import forge.game.card.CounterEnumType; import forge.game.card.CounterType; @@ -46,7 +47,16 @@ * @author Forge * @version $Id$ */ -public class Cost implements Serializable { +public class Cost implements Serializable, IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "AffectedZone", "Amount", "Announce", "Collected", "CollectedCards", "Color", + "Cost", "Exiled", "ExiledCards", "FirstForetell", "ForEachShard", "Foraged", + "ForagedCards", "IgnoreGeneric", "MinMana", "ModeCost", "OnlyFirstSpell", + "RaiseCost", "RaiseTo", "ReduceAmount", "ReduceCost", "Relative", + "SpellDescription", "TapCreaturesForMana", "Type", "UnlessValidTarget", "UpTo", + "ValidCard", "ValidSpell", "ValidTarget", + }; + /** * Serializables need a version ID. */ diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java index 48bc00b702ea..22221089ddbf 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbility.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbility.java @@ -74,6 +74,18 @@ * @version $Id$ */ public abstract class SpellAbility extends CardTraitBase implements ISpellAbility, IIdentifiable, Comparable { + public static final String[] OPTIONAL_PARAMS = { + "AlternateCost", "Amount", "Announce", "Boast", "CantCopy", "CloakUp", "CostDesc", + "CumulativeUpkeep", "DisguiseUp", "DividedAsYouChoose", "Exhaust", "Hidden", + "IsCurse", "ManaRestriction", "ManifestUp", "MaxTotalTargetCMC", + "MaxTotalTargetPower", "MorphUp", "Origin", "Planeswalker", "PowerUp", + "PrecostDesc", "SpellDescription", "TargetType", "TargetingPlayer", + "TargetingPlayerControls", "TargetsWithControllerProperty", + "TargetsWithDefinedController", "TargetsWithRelatedProperty", + "TargetsWithSharedCardType", "TargetsWithSharedTypes", "Unlock", "ValidAfterStack", + "WithoutManaCost", "XColor", + }; + private static int maxId = 0; private static int nextId() { return ++maxId; } diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbilityCondition.java b/forge-game/src/main/java/forge/game/spellability/SpellAbilityCondition.java index 5cd2c3e329e5..b4e04609ce7d 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbilityCondition.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbilityCondition.java @@ -24,6 +24,7 @@ import forge.game.GameObjectPredicates; import forge.game.GameType; import forge.game.ability.AbilityUtils; +import forge.game.ability.IHasForgeParams; import forge.game.card.Card; import forge.game.phase.PhaseHandler; import forge.game.phase.PhaseType; @@ -45,7 +46,21 @@ * @version $Id$ * @since 1.0.15 */ -public class SpellAbilityCondition extends SpellAbilityVariables { +public class SpellAbilityCondition extends SpellAbilityVariables implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "Condition", "ConditionActivationLimit", "ConditionCheckSVar", + "ConditionChosenColor", "ConditionCompare", "ConditionCompare2", "ConditionDefined", + "ConditionDefined2", "ConditionFirstCombat", "ConditionGameTypes", + "ConditionLifeAmount", "ConditionLifeTotal", "ConditionManaNotSpent", + "ConditionManaSpent", "ConditionNoDifferentColors", "ConditionNotPresent", + "ConditionOpponentTurn", "ConditionOptionalPaid", "ConditionPhases", + "ConditionPlayerContains", "ConditionPlayerDefined", "ConditionPlayerTurn", + "ConditionPresent", "ConditionPresent2", "ConditionSVarCompare", + "ConditionSorcerySpeed", "ConditionTargetValidTargeting", + "ConditionTargetsSingleTarget", "ConditionZone", "OrConditionCheckSVar", + "OrOtherConditionSVarCompare", + }; + // A class for handling SpellAbility Conditions. These restrictions include: // Zone, Phase, OwnTurn, Speed (instant/sorcery), Amount per Turn, Player, // Threshold, Metalcraft, LevelRange, etc diff --git a/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java b/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java index 21493f18e2a8..9bf0259dc78f 100644 --- a/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java +++ b/forge-game/src/main/java/forge/game/spellability/SpellAbilityRestriction.java @@ -28,6 +28,7 @@ import forge.game.GameObjectPredicates; import forge.game.GameType; import forge.game.ability.AbilityUtils; +import forge.game.ability.IHasForgeParams; import forge.game.card.*; import forge.game.keyword.Keyword; import forge.game.phase.PhaseType; @@ -49,7 +50,17 @@ * @author Forge * @version $Id$ */ -public class SpellAbilityRestriction extends SpellAbilityVariables { +public class SpellAbilityRestriction extends SpellAbilityVariables implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "Activation", "ActivationAfterBlockers", "ActivationFirstCombat", + "ActivationGameTypes", "ActivationLifeAmount", "ActivationLifeTotal", + "ActivationLimit", "ActivationPhases", "ActivationZone", "Activator", + "AdditionalActivationZone", "Affected", "CheckSVar", "ClassLevel", + "GameActivationLimit", "InstantSpeed", "IsPresent", "OpponentTurn", "PlayerTurn", + "PresentCompare", "PresentDefined", "PresentZone", "SVarCompare", "SorcerySpeed", + "ValidSA", + }; + // A class for handling SpellAbility Restrictions. These restrictions include: // Zone, Phase, OwnTurn, Speed (instant/sorcery), Amount per Turn, Player, // Threshold, Metalcraft, LevelRange, etc diff --git a/forge-game/src/main/java/forge/game/spellability/TargetRestrictions.java b/forge-game/src/main/java/forge/game/spellability/TargetRestrictions.java index 943bcc4006c7..9a9453903507 100644 --- a/forge-game/src/main/java/forge/game/spellability/TargetRestrictions.java +++ b/forge-game/src/main/java/forge/game/spellability/TargetRestrictions.java @@ -28,6 +28,7 @@ import forge.game.Game; import forge.game.GameEntity; import forge.game.ability.AbilityUtils; +import forge.game.ability.IHasForgeParams; import forge.game.card.Card; import forge.game.player.Player; import forge.game.zone.ZoneType; @@ -42,7 +43,17 @@ * @author Forge * @version $Id$ */ -public class TargetRestrictions { +public class TargetRestrictions implements IHasForgeParams { + public static final String[] OPTIONAL_PARAMS = { + "MaxTotalTargetCMC", "MaxTotalTargetPower", "RandomNumTargets", "TargetMax", + "TargetMin", "TargetUnique", "TargetValidTargeting", "TargetingPlayer", + "TargetsAtRandom", "TargetsForEachPlayer", "TargetsWithDifferentCMC", + "TargetsWithDifferentControllers", "TargetsWithDifferentNames", + "TargetsWithEqualToughness", "TargetsWithSameCardType", "TargetsWithSameController", + "TargetsWithSameCreatureType", "TargetsWithoutSameCreatureType", "TgtPrompt", + "TgtZone", "ValidTgts", "ValidTgtsDesc", + }; + // Target has two things happening: // Targeting restrictions (Creature, Min/Maxm etc) which are true for this // What this Object is restricted to targeting diff --git a/forge-game/src/test/java/forge/game/ability/CardScriptParamDeclarationTest.java b/forge-game/src/test/java/forge/game/ability/CardScriptParamDeclarationTest.java new file mode 100644 index 000000000000..2a25c3026bf8 --- /dev/null +++ b/forge-game/src/test/java/forge/game/ability/CardScriptParamDeclarationTest.java @@ -0,0 +1,325 @@ +package forge.game.ability; + +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import com.google.common.reflect.ClassPath; +import org.testng.annotations.Test; + +/** + * Guards the card-script parameter declarations against drift. A framework or effect class + * that reads card-script parameters declares them in OPTIONAL_PARAMS; effects may also group + * mutually-required params in REQUIRED_PARAMS. This test fails the build if such a class reads + * a parameter it does not declare, or declares one that appears nowhere in its source. + * + * Reads are detected from literal string arguments -- getParam("X"), the defined-resolution + * helpers, map lookups, and so on. A parameter read through a non-literal argument cannot be + * detected, so a passing run means "no undeclared literal read", not a proof of completeness. + * + * Declarers are found by scanning the classpath for implementors of {@link IHasForgeParams}: a + * class is covered iff it implements that interface and lists its params, so there is no path or + * package list to maintain. Listing a param field without implementing the interface fails the + * test, so discovery and declaration cannot drift apart. + */ +public class CardScriptParamDeclarationTest { + + // The literal forms a card-script parameter read takes in source + private static final Pattern GETPARAM = Pattern.compile( + "(? MARKERS = Set.of("AB", "SP", "ST", "DB"); + + // Package scanned for declarers (classes implementing IHasForgeParams) -- no path list to maintain + private static final String SCAN_PACKAGE = "forge.game"; + + // Where a discovered forge-game class's source lives, given its package path + private static final String GAME_SRC = "forge-game/src/main/java"; + + // Floor for the scan: a partial scan would silently drop declarers (a dropped reader stops + // gating its reads), so require these to be found and fail loudly on any shortfall. + private static final Set EXPECTED_FRAMEWORK = Set.of( + "AbilityFactory", "AbilityUtils", "SpellAbilityEffect", "CardTraitBase", "Cost", + "SpellAbility", "SpellAbilityCondition", "SpellAbilityRestriction", "TargetRestrictions"); + + // forge-ai declarers are found by walking this source root: the forge-game test classpath can't + // see forge-ai (the dependency runs forge-ai -> forge-game), so the classpath scan can't reach them. + private static final String AI_SRC = "forge-ai/src/main/java"; + + // Effects own their own params; everything else that declares forms the shared base layer + private static final String EFFECTS_DIR = "/ability/effects/"; + + // Assignment of an array initializer to a field -- tolerates an explicit "= new String[]{" + private static final String ASSIGN = "\\s*=\\s*[^;]*?\\{"; + + // A source file declares params iff it assigns one of these fields an array initializer + private static final Pattern DECLARES = Pattern.compile("(?:OPTIONAL_PARAMS|REQUIRED_PARAMS)" + ASSIGN); + + // Cross-cutting readers read params owned by other classes, so their reads aren't gated; their + // declared params still join the base and face the structural checks. + private static final Set READ_GATE_EXEMPT = Set.of("SpellAbilityAi"); + + @Test + public void declarationsDoNotDrift() throws IOException { + Path root = locateRoot(); + List errors = new ArrayList<>(); + + // Discover declarers by scanning the classpath for IHasForgeParams implementors + List declarers = new ArrayList<>(); + Set found = new TreeSet<>(); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + for (ClassPath.ClassInfo info : ClassPath.from(cl).getTopLevelClassesRecursive(SCAN_PACKAGE)) { + Class c; + boolean declares; + try { + c = Class.forName(info.getName(), false, cl); + declares = declaresOwnParams(c); + } catch (Throwable t) { + // A class that won't load/link can't be one of our plain declarers; skip it + continue; + } + if (!declares) { + continue; + } + if (!IHasForgeParams.class.isAssignableFrom(c)) { + errors.add(c.getSimpleName() + ": declares card-script params but does not implement IHasForgeParams"); + continue; + } + Path src = root.resolve(GAME_SRC).resolve(c.getName().replace('.', '/') + ".java"); + if (Files.exists(src)) { + declarers.add(src); + found.add(c.getSimpleName()); + } else { + errors.add(c.getName() + ": implements IHasForgeParams but its source was not found under " + GAME_SRC); + } + } + + // Fail loudly on a partial scan instead of passing on a subset (see EXPECTED_FRAMEWORK) + Set missing = new TreeSet<>(EXPECTED_FRAMEWORK); + missing.removeAll(found); + assertTrue(missing.isEmpty(), "classpath scan missed expected declarers (partial/broken scan?): " + missing); + + // forge-ai declarers can't be reached by the classpath scan (see AI_SRC), so walk its source + Path aiSrc = root.resolve(AI_SRC); + assertTrue(Files.isDirectory(aiSrc), "forge-ai source root not found: " + AI_SRC); + int beforeAi = declarers.size(); + try (Stream walk = Files.walk(aiSrc)) { + for (Path p : (Iterable) walk.filter(f -> f.toString().endsWith(".java"))::iterator) { + if (declaresInSource(read(p))) { + declarers.add(p); + } + } + } + assertTrue(declarers.size() > beforeAi, "no forge-ai param declarers found under " + AI_SRC + " -- discovery is broken"); + + // Base = declarers that aren't effects; their optional params are inherited by every effect + Set base = new TreeSet<>(); + for (Path f : declarers) { + if (!isEffect(f)) { + base.addAll(declared(read(f), "OPTIONAL_PARAMS")); + } + } + + for (Path f : declarers) { + checkClass(f, base, isEffect(f), errors); + } + + assertTrue(errors.isEmpty(), + "Card-script param declarations are out of sync with the code:\n " + String.join("\n ", errors)); + } + + private void checkClass(Path file, Set base, boolean isEffect, List errors) throws IOException { + String src = read(file); + String name = file.getFileName().toString().replace(".java", ""); + Set optional = declared(src, "OPTIONAL_PARAMS"); + List> requiredGroups = requiredGroups(src); + Set requiredFlat = new TreeSet<>(); + requiredGroups.forEach(requiredFlat::addAll); + Set own = new TreeSet<>(optional); + own.addAll(requiredFlat); + + // (1) every param the class reads must be declared (own) or inherited (base) + if (!READ_GATE_EXEMPT.contains(name)) { + Set allowed = new TreeSet<>(own); + allowed.addAll(base); + for (String p : reads(src)) { + if (!allowed.contains(p)) { + errors.add(name + ": reads '" + p + "$' but it is not declared (add to OPTIONAL_PARAMS)"); + } + } + } + // (2) every declared param must appear as a literal somewhere in the source (no typos) + Set literals = literals(src); + for (String p : own) { + if (!literals.contains(p)) { + errors.add(name + ": declares '" + p + "$' but it appears nowhere in the source (typo?)"); + } + } + // (3) effects must not re-declare base params + if (isEffect) { + for (String p : own) { + if (base.contains(p)) { + errors.add(name + ": re-declares base param '" + p + "$' (it is already inherited)"); + } + } + } + // (4) required and optional are disjoint + for (String p : requiredFlat) { + if (optional.contains(p)) { + errors.add(name + ": '" + p + "$' is in both REQUIRED_PARAMS and OPTIONAL_PARAMS"); + } + } + // (5) no duplicate optional entries + List rawOpt = declaredList(src, "OPTIONAL_PARAMS"); + if (rawOpt.size() != new LinkedHashSet<>(rawOpt).size()) { + errors.add(name + ": OPTIONAL_PARAMS has duplicate entries"); + } + // (6) required groups are non-empty + for (Set g : requiredGroups) { + if (g.isEmpty()) { + errors.add(name + ": REQUIRED_PARAMS has an empty one-of group"); + } + } + // (7) every declared entry has a valid param-token shape + for (String p : own) { + if (!p.matches("[A-Za-z][A-Za-z0-9]*")) { + errors.add(name + ": '" + p + "' is not a valid param token"); + } + } + } + + private boolean declaresOwnParams(Class c) { + return hasOwnField(c, "OPTIONAL_PARAMS") || hasOwnField(c, "REQUIRED_PARAMS"); + } + + private boolean declaresInSource(String src) { + return DECLARES.matcher(src).find(); + } + + private boolean hasOwnField(Class c, String name) { + for (Field f : c.getDeclaredFields()) { + if (f.getName().equals(name)) { + return true; + } + } + return false; + } + + private boolean isEffect(Path file) { + return file.toString().replace('\\', '/').contains(EFFECTS_DIR); + } + + private Set declared(String src, String field) { + return new TreeSet<>(declaredList(src, field)); + } + + private List declaredList(String src, String field) { + List out = new ArrayList<>(); + Matcher decl = Pattern.compile(field + ASSIGN).matcher(src); + if (!decl.find()) { + return out; + } + int open = decl.end() - 1; + int close = src.indexOf("};", open); + if (close < 0) { + return out; + } + Matcher m = STRING_LITERAL.matcher(src.substring(open, close)); + while (m.find()) { + out.add(m.group(1)); + } + return out; + } + + /** REQUIRED_PARAMS is String[][]; return one set per inner { } group. */ + private List> requiredGroups(String src) { + List> groups = new ArrayList<>(); + Matcher decl = Pattern.compile("REQUIRED_PARAMS" + ASSIGN).matcher(src); + if (!decl.find()) { + return groups; + } + int open = decl.end() - 1; + int close = src.indexOf("};", open); + if (close < 0) { + return groups; + } + Matcher inner = Pattern.compile("\\{([^{}]*)\\}").matcher(src.substring(open + 1, close)); + while (inner.find()) { + Set g = new TreeSet<>(); + Matcher m = STRING_LITERAL.matcher(inner.group(1)); + while (m.find()) { + g.add(m.group(1)); + } + groups.add(g); + } + return groups; + } + + private Set reads(String src) { + Set out = new TreeSet<>(); + collect(GETPARAM, src, out); + collect(DEFINED, src, out); + collect(MAPGET, src, out); + collect(KEYVAR, src, out); + Matcher c = ADD_TO_COMBAT.matcher(src); + while (c.find()) { + Matcher m = STRING_LITERAL.matcher(c.group(1)); + while (m.find()) { + out.add(m.group(1)); + } + } + out.removeIf(p -> MARKERS.contains(p) || !p.matches("[A-Za-z][A-Za-z0-9]*")); + return out; + } + + private Set literals(String src) { + Set out = new TreeSet<>(); + collect(STRING_LITERAL, src, out); + return out; + } + + private void collect(Pattern p, String src, Set out) { + Matcher m = p.matcher(src); + while (m.find()) { + out.add(m.group(1)); + } + } + + private static Path locateRoot() { + Path d = Paths.get("").toAbsolutePath(); + for (int i = 0; i < 8 && d != null; i++) { + if (Files.isDirectory(d.resolve("forge-game/src/main/java"))) { + return d; + } + d = d.getParent(); + } + throw new IllegalStateException("could not locate repo root from " + Paths.get("").toAbsolutePath()); + } + + private static String read(Path p) throws IOException { + return new String(Files.readAllBytes(p), StandardCharsets.UTF_8); + } +}