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..5ba46bddde 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,12 @@ 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); + + for (var replacement : XMLUtils.flattenElements( + doc.getRootElement(), Set.of("replacements"), replacementParser.replacementTypes())) { + 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 6ce3232b58..84b1411992 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; @@ -59,7 +56,6 @@ 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 +64,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 +77,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( @@ -295,77 +291,14 @@ public > MessageAction parseChatMessage(Element el, C 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..ad5ff98165 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,13 +1,12 @@ 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.action.replacements.Replacement; import tc.oc.pgm.util.Audience; public class MessageAction extends AbstractAction { @@ -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.replace(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..17e10eebda --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/Replacement.java @@ -0,0 +1,27 @@ +package tc.oc.pgm.action.replacements; + +import net.kyori.adventure.text.ComponentLike; +import tc.oc.pgm.api.feature.FeatureDefinition; +import tc.oc.pgm.util.Audience; + +/** + * A replacement object provides replacement text in message actions. + * + * @param The scope of the replacement + */ +public interface Replacement extends FeatureDefinition { + /** + * Gets the scope of the replacement. + * + * @return The scope of the replacement + */ + Class getScope(); + + /** + * Creates a replacement component tailored to the given audience. + * + * @param audience The audience to use when replacing + * @return The replacement component + */ + ComponentLike replace(S audience); +} 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..b2e6f8aa2b --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/ReplacementParser.java @@ -0,0 +1,193 @@ +package tc.oc.pgm.action.replacements; + +import static net.kyori.adventure.text.Component.empty; + +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 org.jdom2.Element; +import org.jetbrains.annotations.Nullable; +import tc.oc.pgm.action.replacements.impl.DecimalReplacement; +import tc.oc.pgm.action.replacements.impl.PlayerReplacement; +import tc.oc.pgm.action.replacements.impl.SwitchReplacement; +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.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; +import tc.oc.pgm.util.xml.XMLUtils; + +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 bound) + throws InvalidXMLException { + Replacement result = parseDynamic(el, bound); + if (result instanceof Replacement replacement && isTopLevel) { + features.addFeature(el, replacement); + } + return result; + } + + public Set replacementTypes() { + return methodParsers.keySet(); + } + + protected Method getParserFor(Element el) { + return methodParsers.get(el.getName().toLowerCase()); + } + + @SuppressWarnings("unchecked") + private > Replacement parseDynamic(Element el, 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); + } + } + + 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.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); + Formula formula = parser.formula(scope, el, "value").required(); + var formatString = parser.string(el, "format").attr().orNull(); + NumberFormat format = formatString != null ? new DecimalFormat(formatString) : DEFAULT_FORMAT; + return new DecimalReplacement<>(scope, formula, format); + } + + @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); + // Double cast is required, otherwise the compiler complains about type incompatibility + @SuppressWarnings("unchecked,RedundantCast") + Class _scope = scope == null ? (Class) (Object) Filterable.class : scope; + return new PlayerReplacement<>(_scope, variable, fallback, nameStyle); + } + + @MethodParser("switch") + public > Replacement parseSwitch(Element el, Class scope) + throws InvalidXMLException { + scope = parseScope(el, scope); + 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 match attribute must be specified", innerEl); + } + + if (valueRange != null && formula == null) { + throw new InvalidXMLException( + "A match attribute is specified but there's no switch value to bind to", innerEl); + } + + var result = parser.formattedText(innerEl, "result").required(); + + if (filter != null) { + Class finalScope = scope; + features.validate( + filter, + (f, n) -> { + if (!f.respondsTo(finalScope)) { + throw new InvalidXMLException( + "Expected a filter that can respond to " + + finalScope.getSimpleName() + + ". The filter " + + f.getDefinitionType().getSimpleName() + + " only responds to " + + Filterables.scope(f).getSimpleName() + + " or lower", + n); + } + }, + Node.fromChildOrAttr(innerEl, "filter")); + } + + branches.add(new SwitchReplacement.Branch( + result, + valueRange == null ? Range.all() : valueRange, + filter == null ? StaticFilter.ALLOW : filter)); + } + + return new SwitchReplacement<>(scope, branches, fallback, formula); + } + + @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); + + @SuppressWarnings("unchecked") + Replacement replacement = (Replacement) features.resolve(new Node(el), Replacement.class); + + validateScope(replacement, scope, el); + return replacement; + } + + private void validateScope(Replacement definition, Class scope, Element el) + throws InvalidXMLException { + Class definitionScope = definition.getScope(); + if (!definitionScope.isAssignableFrom(scope)) + throw new InvalidXMLException( + "Wrong replacement scope, got " + + definitionScope.getSimpleName() + + " but expected " + + scope.getSimpleName(), + el); + } +} diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/impl/DecimalReplacement.java b/core/src/main/java/tc/oc/pgm/action/replacements/impl/DecimalReplacement.java new file mode 100644 index 0000000000..3d1a1388b2 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/impl/DecimalReplacement.java @@ -0,0 +1,31 @@ +package tc.oc.pgm.action.replacements.impl; + +import static net.kyori.adventure.text.Component.text; + +import java.text.NumberFormat; +import net.kyori.adventure.text.ComponentLike; +import tc.oc.pgm.action.replacements.Replacement; +import tc.oc.pgm.util.Audience; +import tc.oc.pgm.util.math.Formula; + +public class DecimalReplacement implements Replacement { + private final Class scope; + private final Formula formula; + private final NumberFormat format; + + public DecimalReplacement(Class scope, Formula formula, NumberFormat format) { + this.scope = scope; + this.formula = formula; + this.format = format; + } + + @Override + public Class getScope() { + return scope; + } + + @Override + public ComponentLike replace(S audience) { + return text(format.format(formula.applyAsDouble(audience))); + } +} diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/impl/PlayerReplacement.java b/core/src/main/java/tc/oc/pgm/action/replacements/impl/PlayerReplacement.java new file mode 100644 index 0000000000..48cf2c9f55 --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/impl/PlayerReplacement.java @@ -0,0 +1,39 @@ +package tc.oc.pgm.action.replacements.impl; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import org.jetbrains.annotations.Nullable; +import tc.oc.pgm.action.replacements.Replacement; +import tc.oc.pgm.api.player.MatchPlayer; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.util.named.NameStyle; +import tc.oc.pgm.variables.Variable; + +public class PlayerReplacement> implements Replacement { + private final Class scope; + private final Variable.Exclusive variable; + private final Component fallback; + private final NameStyle nameStyle; + + public PlayerReplacement( + Class scope, + Variable.Exclusive variable, + Component fallback, + NameStyle nameStyle) { + this.scope = scope; + this.variable = variable; + this.fallback = fallback; + this.nameStyle = nameStyle; + } + + @Nullable + @Override + public Class getScope() { + return scope; + } + + @Override + public ComponentLike replace(S player) { + return variable.getHolder(player).map(mp -> mp.getName(nameStyle)).orElse(fallback); + } +} diff --git a/core/src/main/java/tc/oc/pgm/action/replacements/impl/SwitchReplacement.java b/core/src/main/java/tc/oc/pgm/action/replacements/impl/SwitchReplacement.java new file mode 100644 index 0000000000..a9890ac59a --- /dev/null +++ b/core/src/main/java/tc/oc/pgm/action/replacements/impl/SwitchReplacement.java @@ -0,0 +1,47 @@ +package tc.oc.pgm.action.replacements.impl; + +import com.google.common.collect.Range; +import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import org.jetbrains.annotations.Nullable; +import tc.oc.pgm.action.replacements.Replacement; +import tc.oc.pgm.api.filter.Filter; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.util.math.Formula; + +public class SwitchReplacement> implements Replacement { + private final Class scope; + + @Nullable + private final Formula formula; + + private final ComponentLike fallback; + private final List branches; + + public SwitchReplacement( + Class scope, List branches, ComponentLike fallback, @Nullable Formula formula) { + this.scope = scope; + this.formula = formula; + this.fallback = fallback; + this.branches = branches; + } + + @Override + public Class getScope() { + return scope; + } + + @Override + public ComponentLike replace(T filterable) { + var formulaResult = formula != null ? formula.applyAsDouble(filterable) : null; + for (var branch : branches) { + if ((formula == null || branch.valueRange.contains(formulaResult)) + && branch.filter.query(filterable).isAllowed()) return branch.result; + } + + return fallback; + } + + public record Branch(Component result, Range valueRange, Filter filter) {} +}