From ac177395f24fbb73e58a1bccf70cf0716e1393c5 Mon Sep 17 00:00:00 2001 From: TTtie Date: Sun, 15 Dec 2024 15:41:47 +0000 Subject: [PATCH] feat,refactor(replacements): add a global replacement registry Allows for global replacements to be registered on the map level: ```xml ``` Where required, a global replacement requires a matching scope (in actions, the scope is inherited from the current scope): ```xml ... ... ``` A globally defined replacement then can be referred to from within as: ```xml global-replacement ``` To support for this, replacement parsing has been forked out of ActionParser. Signed-off-by: TTtie --- .../java/tc/oc/pgm/action/ActionModule.java | 8 + .../java/tc/oc/pgm/action/ActionParser.java | 82 +-------- .../oc/pgm/action/actions/MessageAction.java | 29 ++-- .../pgm/action/replacements/Replacement.java | 26 +++ .../replacements/ReplacementParser.java | 164 ++++++++++++++++++ .../replacements/ScopedReplacement.java | 45 +++++ .../tc/oc/pgm/util/xml/XMLFluentParser.java | 19 ++ .../pgm/util/xml/parsers/FilterBuilder.java | 22 +++ 8 files changed, 303 insertions(+), 92 deletions(-) create mode 100644 core/src/main/java/tc/oc/pgm/action/replacements/Replacement.java create mode 100644 core/src/main/java/tc/oc/pgm/action/replacements/ReplacementParser.java create mode 100644 core/src/main/java/tc/oc/pgm/action/replacements/ScopedReplacement.java diff --git a/core/src/main/java/tc/oc/pgm/action/ActionModule.java b/core/src/main/java/tc/oc/pgm/action/ActionModule.java index b3d9e59960..9f83a60523 100644 --- a/core/src/main/java/tc/oc/pgm/action/ActionModule.java +++ b/core/src/main/java/tc/oc/pgm/action/ActionModule.java @@ -8,6 +8,7 @@ import org.jdom2.Element; import org.jetbrains.annotations.Nullable; import tc.oc.pgm.action.actions.ExposedAction; +import tc.oc.pgm.action.replacements.ReplacementParser; import tc.oc.pgm.api.map.MapModule; import tc.oc.pgm.api.map.factory.MapFactory; import tc.oc.pgm.api.map.factory.MapModuleFactory; @@ -58,6 +59,13 @@ public Collection>> getWeakDependencies() { public ActionModule parse(MapFactory factory, Logger logger, Document doc) throws InvalidXMLException { ActionParser parser = factory.getParser().getActionParser(); + var replacementParser = new ReplacementParser(factory, true); + var features = factory.getFeatures(); + + for (var replacement : XMLUtils.flattenElements( + doc.getRootElement(), Set.of("replacements"), replacementParser.replacementTypes())) { + features.addFeature(replacement, replacementParser.parse(replacement, null)); + } for (Element action : XMLUtils.flattenElements(doc.getRootElement(), Set.of("actions"), parser.actionTypes())) { diff --git a/core/src/main/java/tc/oc/pgm/action/ActionParser.java b/core/src/main/java/tc/oc/pgm/action/ActionParser.java index 5038b46dea..5cfefca428 100644 --- a/core/src/main/java/tc/oc/pgm/action/ActionParser.java +++ b/core/src/main/java/tc/oc/pgm/action/ActionParser.java @@ -3,16 +3,11 @@ import static net.kyori.adventure.key.Key.key; import static net.kyori.adventure.sound.Sound.sound; import static net.kyori.adventure.text.Component.empty; -import static net.kyori.adventure.text.Component.text; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.lang.reflect.Method; -import java.text.DecimalFormat; -import java.text.NumberFormat; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import net.kyori.adventure.sound.Sound; @@ -37,6 +32,8 @@ import tc.oc.pgm.action.actions.TakePaymentAction; import tc.oc.pgm.action.actions.TeleportAction; import tc.oc.pgm.action.actions.VelocityAction; +import tc.oc.pgm.action.replacements.Replacement; +import tc.oc.pgm.action.replacements.ReplacementParser; import tc.oc.pgm.api.feature.FeatureValidation; import tc.oc.pgm.api.filter.Filter; import tc.oc.pgm.api.filter.Filterables; @@ -54,12 +51,10 @@ import tc.oc.pgm.shops.ShopModule; import tc.oc.pgm.shops.menu.Payable; import tc.oc.pgm.structure.StructureDefinition; -import tc.oc.pgm.util.Audience; import tc.oc.pgm.util.MethodParser; import tc.oc.pgm.util.MethodParsers; import tc.oc.pgm.util.inventory.ItemMatcher; import tc.oc.pgm.util.math.Formula; -import tc.oc.pgm.util.named.NameStyle; import tc.oc.pgm.util.xml.InvalidXMLException; import tc.oc.pgm.util.xml.Node; import tc.oc.pgm.util.xml.XMLFluentParser; @@ -68,13 +63,12 @@ public class ActionParser { - private static final NumberFormat DEFAULT_FORMAT = NumberFormat.getIntegerInstance(); - private final MapFactory factory; private final boolean legacy; private final FeatureDefinitionContext features; private final XMLFluentParser parser; private final Map methodParsers; + private final ReplacementParser replacementParser; public ActionParser(MapFactory factory) { this.factory = factory; @@ -82,6 +76,7 @@ public ActionParser(MapFactory factory) { this.features = factory.getFeatures(); this.parser = factory.getParser(); this.methodParsers = MethodParsers.getMethodParsersForClass(getClass()); + replacementParser = new ReplacementParser(factory); } public > Action parseProperty( @@ -290,82 +285,19 @@ public > MessageAction parseChatMessage(Element el, C List replacements = XMLUtils.flattenElements(el, "replacements"); if (replacements.isEmpty()) { - return new MessageAction<>(Audience.class, text, actionbar, title, null); + return new MessageAction<>(Filterable.class, text, actionbar, title, null); } scope = parseScope(el, scope); - ImmutableMap.Builder> replacementMap = - ImmutableMap.builder(); + ImmutableMap.Builder replacementMap = ImmutableMap.builder(); for (Element replacement : XMLUtils.flattenElements(el, "replacements")) { replacementMap.put( - XMLUtils.parseRequiredId(replacement), parseReplacement(replacement, scope)); + XMLUtils.parseRequiredId(replacement), replacementParser.parse(replacement, scope)); } return new MessageAction<>(scope, text, actionbar, title, replacementMap.build()); } - private > MessageAction.Replacement parseReplacement( - Element el, Class scope) throws InvalidXMLException { - // TODO: Support alternative replacement types (eg: player(s), team(s), or durations) - switch (el.getName().toLowerCase(Locale.ROOT)) { - case "decimal": { - Formula formula = parser.formula(scope, el, "value").required(); - Node formatNode = Node.fromAttr(el, "format"); - NumberFormat format = - formatNode != null ? new DecimalFormat(formatNode.getValue()) : DEFAULT_FORMAT; - return (T filterable) -> text(format.format(formula.applyAsDouble(filterable))); - } - case "player": { - var variable = parser.variable(el, "var").scope(MatchPlayer.class).singleExclusive(); - var fallback = XMLUtils.parseFormattedText(el, "fallback", empty()); - var nameStyle = parser.parseEnum(NameStyle.class, el, "style").optional(NameStyle.VERBOSE); - - return (T filterable) -> - variable.getHolder(filterable).map(mp -> mp.getName(nameStyle)).orElse(fallback); - } - case "switch": { - Formula formula = parser.formula(scope, el, "value").orNull(); - var fallback = parser.formattedText(el, "fallback").child().optional(empty()); - var children = el.getChildren("case"); - var branches = new ArrayList(children.size()); - - for (var innerEl : children) { - var filter = parser.filter(innerEl, "filter").orNull(); - var valueRange = XMLUtils.parseNumericRange( - Node.fromChildOrAttr(innerEl, "match"), Double.class, null); - if (filter == null && valueRange == null) { - throw new InvalidXMLException( - "At least a filter or a value must be specified", innerEl); - } - - if (valueRange != null && formula == null) { - throw new InvalidXMLException( - "A value attribute is specified but there's no switch value to bind to", innerEl); - } - - var result = parser.formattedText(innerEl, "result").required(); - - branches.add(new CaseBranch( - result, - valueRange == null ? Range.all() : valueRange, - filter == null ? StaticFilter.ALLOW : filter)); - } - - return (T filterable) -> { - var formulaResult = formula == null ? null : formula.applyAsDouble(filterable); - for (var branch : branches) { - if ((formula == null || branch.valueRange.contains(formulaResult)) - && branch.filter.query(filterable).isAllowed()) return branch.result; - } - - return fallback; - }; - } - default: - throw new InvalidXMLException("Unknown replacement type", el); - } - } - @MethodParser("sound") public SoundAction parseSoundAction(Element el, Class scope) throws InvalidXMLException { SoundType soundType = diff --git a/core/src/main/java/tc/oc/pgm/action/actions/MessageAction.java b/core/src/main/java/tc/oc/pgm/action/actions/MessageAction.java index 18aacb9b73..52d09de8a9 100644 --- a/core/src/main/java/tc/oc/pgm/action/actions/MessageAction.java +++ b/core/src/main/java/tc/oc/pgm/action/actions/MessageAction.java @@ -1,29 +1,28 @@ package tc.oc.pgm.action.actions; import java.util.Map; -import java.util.function.Function; import java.util.regex.Pattern; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentLike; import net.kyori.adventure.text.TextReplacementConfig; import net.kyori.adventure.title.Title; import org.jetbrains.annotations.Nullable; -import tc.oc.pgm.util.Audience; +import tc.oc.pgm.action.replacements.Replacement; +import tc.oc.pgm.filters.Filterable; -public class MessageAction extends AbstractAction { +public class MessageAction> extends AbstractAction { private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("\\{(.+?)}"); private final Component text; private final Component actionbar; private final Title title; - private final Map> replacements; + private final Map replacements; public MessageAction( Class scope, @Nullable Component text, @Nullable Component actionbar, @Nullable Title title, - @Nullable Map> replacements) { + @Nullable Map replacements) { super(scope); this.text = text; this.actionbar = actionbar; @@ -43,15 +42,13 @@ private Component replace(Component component, T scope) { return component; } - return component.replaceText( - TextReplacementConfig.builder() - .match(REPLACEMENT_PATTERN) - .replacement( - (match, original) -> { - Replacement r = replacements.get(match.group(1)); - return r != null ? r.apply(scope) : original; - }) - .build()); + return component.replaceText(TextReplacementConfig.builder() + .match(REPLACEMENT_PATTERN) + .replacement((match, original) -> { + Replacement r = replacements.get(match.group(1)); + return r != null ? r.get(scope) : original; + }) + .build()); } private Title replace(Title title, T scope) { @@ -59,6 +56,4 @@ private Title replace(Title title, T scope) { return Title.title( replace(title.title(), scope), replace(title.subtitle(), scope), title.times()); } - - public interface Replacement extends Function {} } diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/Replacement.java b/core/src/main/java/tc/oc/pgm/action/replacements/Replacement.java new file mode 100644 index 0000000000..c1167fddf0 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/Replacement.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.action.replacements; + +import net.kyori.adventure.text.ComponentLike; +import tc.oc.pgm.api.feature.FeatureDefinition; +import tc.oc.pgm.filters.Filterable; + +/** A replacement object provides replacement text in message actions. */ +public interface Replacement extends FeatureDefinition { + /** + * Tests if the replacement can be used with the passed filterable. + * + * @param filterable The filterable to test + * @return Whether the replacement can be used with the passed filterable + */ + default boolean canUse(Class> filterable) { + return true; + } + + /** + * Creates a replacement component tailored to the given filterable. + * + * @param filterable The filterable to use when creating the replacement component + * @return The replacement component + */ + ComponentLike get(Filterable filterable); +} diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/ReplacementParser.java b/core/src/main/java/tc/oc/pgm/action/replacements/ReplacementParser.java new file mode 100644 index 0000000000..3a1ce6af91 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/ReplacementParser.java @@ -0,0 +1,164 @@ +package tc.oc.pgm.action.replacements; + +import static net.kyori.adventure.text.Component.empty; +import static net.kyori.adventure.text.Component.text; + +import com.google.common.collect.Range; +import java.lang.reflect.Method; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Map; +import java.util.Set; +import net.kyori.adventure.text.Component; +import org.jdom2.Element; +import org.jetbrains.annotations.Nullable; +import tc.oc.pgm.api.filter.Filter; +import tc.oc.pgm.api.filter.Filterables; +import tc.oc.pgm.api.map.factory.MapFactory; +import tc.oc.pgm.api.player.MatchPlayer; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.util.MethodParser; +import tc.oc.pgm.util.MethodParsers; +import tc.oc.pgm.util.named.NameStyle; +import tc.oc.pgm.util.xml.InvalidXMLException; +import tc.oc.pgm.util.xml.Node; +import tc.oc.pgm.util.xml.XMLFluentParser; + +public class ReplacementParser { + private static final NumberFormat DEFAULT_FORMAT = NumberFormat.getIntegerInstance(); + private final Map methodParsers = + MethodParsers.getMethodParsersForClass(getClass()); + private final FeatureDefinitionContext features; + private final XMLFluentParser parser; + private final boolean isTopLevel; + + public ReplacementParser(MapFactory factory, boolean topLevel) { + features = factory.getFeatures(); + parser = factory.getParser(); + isTopLevel = topLevel; + } + + public ReplacementParser(MapFactory factory) { + this(factory, false); + } + + public > Replacement parse(Element el, @Nullable Class scope) + throws InvalidXMLException { + Method parser = getParserFor(el); + if (parser != null) { + try { + return (Replacement) parser.invoke(this, el, scope); + } catch (Exception e) { + throw InvalidXMLException.coerce(e, new Node(el)); + } + } else { + throw new InvalidXMLException("Unknown replacement type: " + el.getName(), el); + } + } + + public Set replacementTypes() { + return methodParsers.keySet(); + } + + protected Method getParserFor(Element el) { + return methodParsers.get(el.getName().toLowerCase()); + } + + private > Class parseScope(Element el, Class scope) + throws InvalidXMLException { + if (scope == null) return Filterables.parse(Node.fromRequiredAttr(el, "scope")); + Node node = Node.fromAttr(el, "scope"); + if (node != null && !Filterables.isAssignable(Filterables.parse(node), scope)) + throw new InvalidXMLException( + "Wrong scope defined for replacement, scope must be " + scope.getSimpleName(), el); + return scope; + } + + @MethodParser("decimal") + public > Replacement parseDecimal(Element el, Class scope) + throws InvalidXMLException { + scope = parseScope(el, scope); + var formula = parser.formula(scope, el, "value").required(); + var format = parser + .string(el, "format") + .attr() + .optional() + .map(DecimalFormat::new) + .orElse(DEFAULT_FORMAT); + + return ScopedReplacement.of(scope, ctx -> text(format.format(formula.applyAsDouble(ctx)))); + } + + @MethodParser("player") + public > Replacement parsePlayer(Element el, Class scope) + throws InvalidXMLException { + var variable = parser.variable(el, "var").scope(MatchPlayer.class).singleExclusive(); + var fallback = parser.formattedText(el, "fallback").optional(empty()); + var nameStyle = parser.parseEnum(NameStyle.class, el, "style").optional(NameStyle.VERBOSE); + return filterable -> + variable.getHolder(filterable).map(mp -> mp.getName(nameStyle)).orElse(fallback); + } + + @MethodParser("switch") + public > Replacement parseSwitch(Element el, Class scope) + throws InvalidXMLException { + scope = parseScope(el, scope); + var formula = parser.formula(scope, el, "value").orNull(); + var fallback = parser.formattedText(el, "fallback").child().optional(empty()); + var children = el.getChildren("case"); + var branches = new ArrayList(children.size()); + + for (var innerEl : children) { + var valueRange = parser + .doubleRange(innerEl, "match") + .validate((r, n) -> { + if (formula == null) { + throw new InvalidXMLException( + "A match attribute is specified but there's no switch value to bind to", n); + } + }) + .optional(); + var filter = parser.filter(innerEl, "filter").respondsTo(scope).optional(() -> { + if (valueRange.isEmpty()) + throw new InvalidXMLException( + "At least a filter or a match attribute must be specified", innerEl); + return StaticFilter.ALLOW; + }); + var result = parser.formattedText(innerEl, "result").required(); + branches.add(new SwitchBranch(result, valueRange.orElse(Range.all()), filter)); + } + + return ScopedReplacement.of(scope, ctx -> { + var formulaResult = formula != null ? formula.applyAsDouble(ctx) : null; + for (var branch : branches) { + if ((formula == null || branch.valueRange.contains(formulaResult)) + && branch.filter.query(ctx).isAllowed()) return branch.result; + } + + return fallback; + }); + } + + @MethodParser("replacement") + public > Replacement parseReference(Element el, Class scope) + throws InvalidXMLException { + if (isTopLevel) + throw new InvalidXMLException( + "References to replacements at the root level are not allowed", el); + + Replacement replacement = features.resolve(new Node(el), Replacement.class); + + if (!replacement.canUse(scope)) { + throw new InvalidXMLException( + replacement.getClass().getSimpleName() + " cannot be used with " + scope.getSimpleName(), + el); + } + + return replacement; + } + + public record SwitchBranch(Component result, Range valueRange, Filter filter) {} +} diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/ScopedReplacement.java b/core/src/main/java/tc/oc/pgm/action/replacements/ScopedReplacement.java new file mode 100644 index 0000000000..f0a2f4339a --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/ScopedReplacement.java @@ -0,0 +1,45 @@ +package tc.oc.pgm.action.replacements; + +import java.util.function.Function; +import net.kyori.adventure.text.ComponentLike; +import tc.oc.pgm.api.filter.Filterables; +import tc.oc.pgm.filters.Filterable; + +public abstract class ScopedReplacement> implements Replacement { + private final Class scope; + + public ScopedReplacement(Class scope) { + this.scope = scope; + } + + @Override + public boolean canUse(Class> filterable) { + return Filterables.isAssignable(filterable, scope); + } + + protected abstract ComponentLike getImpl(S ctx); + + @Override + public ComponentLike get(Filterable filterable) { + S ctx = filterable.getFilterableAncestor(scope); + if (ctx == null) + throw new IllegalStateException("Wrong replacement scope for '" + + this + + "', expected " + + scope.getSimpleName() + + " which cannot be found in " + + filterable.getClass().getSimpleName()); + + return getImpl(ctx); + } + + public static > ScopedReplacement of( + Class scope, Function get) { + return new ScopedReplacement<>(scope) { + @Override + protected ComponentLike getImpl(S ctx) { + return get.apply(ctx); + } + }; + } +} diff --git a/core/src/main/java/tc/oc/pgm/util/xml/XMLFluentParser.java b/core/src/main/java/tc/oc/pgm/util/xml/XMLFluentParser.java index b713c076ea..65948e5a4f 100644 --- a/core/src/main/java/tc/oc/pgm/util/xml/XMLFluentParser.java +++ b/core/src/main/java/tc/oc/pgm/util/xml/XMLFluentParser.java @@ -1,5 +1,6 @@ package tc.oc.pgm.util.xml; +import com.google.common.collect.Range; import java.time.Duration; import net.kyori.adventure.text.Component; import org.bukkit.Material; @@ -95,6 +96,24 @@ public NumberBuilder number(Class cls, Element el, Stri return new NumberBuilder<>(cls, el, prop); } + public Builder.Generic> intRange(Element el, String... prop) { + return numericRange(Integer.class, el, prop); + } + + public Builder.Generic> doubleRange(Element el, String... prop) { + return numericRange(Double.class, el, prop); + } + + public > Builder.Generic> numericRange( + Class cls, Element el, String... prop) { + return new Builder.Generic<>(el, prop) { + @Override + protected Range parse(Node node) throws InvalidXMLException { + return XMLUtils.parseNumericRange(node, cls); + } + }; + } + public Builder.Generic material(Element el, String... prop) { return new Builder.Generic<>(el, prop) { @Override diff --git a/core/src/main/java/tc/oc/pgm/util/xml/parsers/FilterBuilder.java b/core/src/main/java/tc/oc/pgm/util/xml/parsers/FilterBuilder.java index 57372ffeae..9921f52386 100644 --- a/core/src/main/java/tc/oc/pgm/util/xml/parsers/FilterBuilder.java +++ b/core/src/main/java/tc/oc/pgm/util/xml/parsers/FilterBuilder.java @@ -1,7 +1,9 @@ package tc.oc.pgm.util.xml.parsers; import org.jdom2.Element; +import org.jetbrains.annotations.NotNull; import tc.oc.pgm.api.filter.Filter; +import tc.oc.pgm.api.filter.Filterables; import tc.oc.pgm.filters.Filterable; import tc.oc.pgm.filters.matcher.StaticFilter; import tc.oc.pgm.filters.parse.DynamicFilterValidation; @@ -27,6 +29,26 @@ public FilterBuilder dynamic(Class> type) { return this; } + public FilterBuilder respondsTo(@NotNull Class> type) { + validate((filter, node) -> filters.validate( + filter, + (definition, n) -> { + if (!definition.respondsTo(type)) { + throw new InvalidXMLException( + "Expected a filter that can respond to " + + type.getSimpleName() + + ". The filter " + + definition.getDefinitionType().getSimpleName() + + " only responds to " + + Filterables.scope(definition).getSimpleName() + + " or lower", + n); + } + }, + node)); + return this; + } + public Filter orAllow() throws InvalidXMLException { return optional(StaticFilter.ALLOW); }