diff --git a/pom.xml b/pom.xml index ebe3f7c..1de3c82 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,11 @@ minecraft-repo https://libraries.minecraft.net/ + + + placeholderapi + https://repo.extendedclip.com/releases/ + @@ -198,6 +203,13 @@ commons-math3 3.6.1 + + + me.clip + placeholderapi + 2.11.6 + provided + diff --git a/src/main/java/world/bentobox/challenges/database/object/requirements/CheckPapi.java b/src/main/java/world/bentobox/challenges/database/object/requirements/CheckPapi.java new file mode 100644 index 0000000..5b6b738 --- /dev/null +++ b/src/main/java/world/bentobox/challenges/database/object/requirements/CheckPapi.java @@ -0,0 +1,183 @@ +package world.bentobox.challenges.database.object.requirements; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.bukkit.entity.Player; + +import me.clip.placeholderapi.PlaceholderAPI; + +public class CheckPapi { + + /** + * Evaluates the formula by first replacing PAPI placeholders (using the provided Player) + * and then evaluating the resulting expression. The expression is expected to be a series + * of numeric comparisons (using =, <>, <=, >=, <, >) joined by Boolean operators AND and OR. + * + * For example: + * "%aoneblock_my_island_lifetime_count% >= 1000 AND %Level_aoneblock_island_level% >= 100" + * + * If any placeholder evaluates to a non-numeric value or the formula is malformed, false is returned. + * + * @param player the Player used for placeholder replacement. + * @param formula the formula to evaluate. + * @return true if the formula evaluates to true, false otherwise. + */ + public static boolean evaluate(Player player, String formula) { + // Replace PAPI placeholders with actual values using the provided Player. + String parsedFormula = PlaceholderAPI.setPlaceholders(player, formula); + + // Tokenize the parsed formula (tokens are assumed to be separated by whitespace). + List tokens = tokenize(parsedFormula); + if (tokens.isEmpty()) { + return false; + } + + try { + Parser parser = new Parser(tokens); + boolean result = parser.parseExpression(); + // If there are leftover tokens, the expression is malformed. + if (parser.hasNext()) { + return false; + } + return result; + } catch (Exception e) { + // Any error in parsing or evaluation results in false. + return false; + } + } + + /** + * Splits the given string into tokens using whitespace as the delimiter. + * + * @param s the string to tokenize. + * @return a list of tokens. + */ + private static List tokenize(String s) { + return new ArrayList<>(Arrays.asList(s.split("\\s+"))); + } + + /** + * A simple recursive descent parser that evaluates expressions according to the following grammar: + * + *
+     * Expression -> Term { OR Term }
+     * Term       -> Factor { AND Factor }
+     * Factor     -> operand operator operand
+     * 
+ * + * A Factor is expected to be a numeric condition in the form: + * number operator number + * where operator is one of: =, <>, <=, >=, <, or >. + */ + private static class Parser { + private final List tokens; + private int pos = 0; + + public Parser(List tokens) { + this.tokens = tokens; + } + + /** + * Returns true if there are more tokens to process. + */ + public boolean hasNext() { + return pos < tokens.size(); + } + + /** + * Returns the next token without advancing. + */ + public String peek() { + return tokens.get(pos); + } + + /** + * Returns the next token and advances the position. + */ + public String next() { + return tokens.get(pos++); + } + + /** + * Parses an Expression: + * Expression -> Term { OR Term } + */ + public boolean parseExpression() { + boolean value = parseTerm(); + while (hasNext() && peek().equalsIgnoreCase("OR")) { + next(); // consume "OR" + boolean termValue = parseTerm(); + value = value || termValue; + } + return value; + } + + /** + * Parses a Term: + * Term -> Factor { AND Factor } + */ + public boolean parseTerm() { + boolean value = parseFactor(); + while (hasNext() && peek().equalsIgnoreCase("AND")) { + next(); // consume "AND" + boolean factorValue = parseFactor(); + value = value && factorValue; + } + return value; + } + + /** + * Parses a Factor, which is a single condition in the form: + * operand operator operand + * + * For example: "1234 >= 1000" + * + * @return the boolean result of the condition. + */ + public boolean parseFactor() { + // There must be at least three tokens remaining. + if (pos + 2 >= tokens.size()) { + throw new RuntimeException("Incomplete condition"); + } + + String leftOperand = next(); + String operator = next(); + String rightOperand = next(); + + // Validate operator. + if (!operator.equals("=") && !operator.equals("<>") && !operator.equals("<=") && !operator.equals(">=") + && !operator.equals("<") && !operator.equals(">")) { + throw new RuntimeException("Invalid operator: " + operator); + } + + double leftVal, rightVal; + try { + leftVal = Double.parseDouble(leftOperand); + rightVal = Double.parseDouble(rightOperand); + } catch (NumberFormatException e) { + // If either operand is not numeric, return false. + return false; + } + // Evaluate the condition. + switch (operator) { + case "=": + return Double.compare(leftVal, rightVal) == 0; + case "<>": + return Double.compare(leftVal, rightVal) != 0; + case "<=": + return leftVal <= rightVal; + case ">=": + return leftVal >= rightVal; + case "<": + return leftVal < rightVal; + case ">": + return leftVal > rightVal; + default: + // This case is never reached. + return false; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/world/bentobox/challenges/database/object/requirements/OtherRequirements.java b/src/main/java/world/bentobox/challenges/database/object/requirements/OtherRequirements.java index 5f59e7d..318b537 100644 --- a/src/main/java/world/bentobox/challenges/database/object/requirements/OtherRequirements.java +++ b/src/main/java/world/bentobox/challenges/database/object/requirements/OtherRequirements.java @@ -139,6 +139,20 @@ public class OtherRequirements extends Requirements { this.requiredIslandLevel = requiredIslandLevel; } + + /** + * @return the papiString + */ + public String getPapiString() { + return papiString == null ? "" : papiString; + } + + /** + * @param papiString the papiString to set + */ + public void setPapiString(String papiString) { + this.papiString = papiString; + } // --------------------------------------------------------------------- @@ -162,6 +176,7 @@ public class OtherRequirements extends Requirements clone.setRequiredMoney(this.requiredMoney); clone.setTakeMoney(this.takeMoney); clone.setRequiredIslandLevel(this.requiredIslandLevel); + clone.setPapiString(this.papiString); return clone; } @@ -201,4 +216,12 @@ public class OtherRequirements extends Requirements */ @Expose private long requiredIslandLevel; + + /** + * Formulas that include math symbols and PAPI placeholders + */ + @Expose + private String papiString; + + } diff --git a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java index c9264dc..0ea6e83 100644 --- a/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java +++ b/src/main/java/world/bentobox/challenges/panel/admin/EditChallengePanel.java @@ -215,6 +215,7 @@ public class EditChallengePanel extends CommonPanel { panelBuilder.item(12, this.createRequirementButton(RequirementButton.REQUIRED_MONEY)); panelBuilder.item(21, this.createRequirementButton(RequirementButton.REMOVE_MONEY)); + panelBuilder.item(14, this.createRequirementButton(RequirementButton.REQUIRED_PAPI)); panelBuilder.item(23, this.createRequirementButton(RequirementButton.REQUIRED_LEVEL)); panelBuilder.item(25, this.createRequirementButton(RequirementButton.REQUIRED_PERMISSIONS)); @@ -630,7 +631,7 @@ public class EditChallengePanel extends CommonPanel { return this.createInventoryRequirementButton(button); } // Buttons for Other Requirements - case REQUIRED_EXPERIENCE, REMOVE_EXPERIENCE, REQUIRED_LEVEL, REQUIRED_MONEY, REMOVE_MONEY -> { + case REQUIRED_EXPERIENCE, REMOVE_EXPERIENCE, REQUIRED_LEVEL, REQUIRED_MONEY, REMOVE_MONEY, REQUIRED_PAPI -> { return this.createOtherRequirementButton(button); } // Statistics @@ -1098,6 +1099,33 @@ public class EditChallengePanel extends CommonPanel { description.add(""); description.add(this.user.getTranslation(Constants.TIPS + "click-to-change")); } + case REQUIRED_PAPI -> { + if (!requirements.getPapiString().isEmpty()) { + description + .add(this.user.getTranslation(reference + "value", "[formula]", requirements.getPapiString())); + } + icon = new ItemStack( + this.addon.getPlugin().getHooks().getHook("PlaceholderAPI").isPresent() ? Material.PAPER + : Material.BARRIER); + clickHandler = (panel, user, clickType, i) -> { + Consumer stringConsumer = string -> { + if (string != null) { + requirements.setPapiString(string); + } + + // reopen panel + this.build(); + }; + ConversationUtils.createStringInput(stringConsumer, user, + this.user.getTranslation(Constants.CONVERSATIONS + "enter-formula"), ""); + + return true; + }; + glow = false; + + description.add(""); + description.add(this.user.getTranslation(Constants.TIPS + "click-to-change")); + } case REQUIRED_MONEY -> { description.add(this.user.getTranslation(reference + "value", Constants.PARAMETER_NUMBER, String.valueOf(requirements.getRequiredMoney()))); @@ -1701,7 +1729,7 @@ public class EditChallengePanel extends CommonPanel { REQUIRED_LEVEL, REQUIRED_MONEY, REMOVE_MONEY, STATISTIC, STATISTIC_BLOCKS, STATISTIC_ITEMS, STATISTIC_ENTITIES, STATISTIC_AMOUNT, REMOVE_STATISTIC, REQUIRED_MATERIALTAGS, REQUIRED_ENTITYTAGS, REQUIRED_STATISTICS, - REMOVE_STATISTICS, + REMOVE_STATISTICS, REQUIRED_PAPI, } // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 1f70042..fb05675 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -37,6 +37,7 @@ import org.bukkit.util.BoundingBox; import com.google.common.collect.UnmodifiableIterator; +import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; @@ -45,6 +46,7 @@ import world.bentobox.challenges.ChallengesAddon; import world.bentobox.challenges.database.object.Challenge; import world.bentobox.challenges.database.object.Challenge.ChallengeType; import world.bentobox.challenges.database.object.ChallengeLevel; +import world.bentobox.challenges.database.object.requirements.CheckPapi; import world.bentobox.challenges.database.object.requirements.InventoryRequirements; import world.bentobox.challenges.database.object.requirements.IslandRequirements; import world.bentobox.challenges.database.object.requirements.OtherRequirements; @@ -530,91 +532,79 @@ public class TryToComplete } } - // If challenges are in sync with all island members, then punish others too. - if (this.addon.getChallengesSettings().isStoreAsIslandData()) - { - Island island = this.addon.getIslands().getIsland(this.world, this.user); - - if (island == null) { - // hmm - return; - } - - for (UnmodifiableIterator iterator = island.getMemberSet().iterator(); iterator.hasNext() - && removeAmount > 0;) + // If challenges are in sync with all island members, then punish others too. + if (this.addon.getChallengesSettings().isStoreAsIslandData()) { - Player player = Bukkit.getPlayer(iterator.next()); + Island island = this.addon.getIslands().getIsland(this.world, this.user); - if (player == null || player == this.user.getPlayer()) { - // cannot punish null or player who already was punished. - continue; + if (island == null) { + // hmm + return; } - switch (Objects.requireNonNull(s.statistic()).getType()) { - case UNTYPED -> { - int statistic = player.getStatistic(s.statistic()); + for (UnmodifiableIterator iterator = island.getMemberSet().iterator(); iterator.hasNext() + && removeAmount > 0;) { + Player player = Bukkit.getPlayer(iterator.next()); - if (removeAmount >= statistic) - { - removeAmount -= statistic; - player.setStatistic(s.statistic(), 0); + if (player == null || player == this.user.getPlayer()) { + // cannot punish null or player who already was punished. + continue; } - else - { - player.setStatistic(s.statistic(), statistic - removeAmount); - removeAmount = 0; - } - } - case ITEM, BLOCK -> { - if (s.material() == null) - { - // Just a sanity check. Entity cannot be null at this point of code. - removeAmount = 0; - } - else - { - int statistic = player.getStatistic(s.statistic(), s.material()); + + switch (Objects.requireNonNull(s.statistic()).getType()) { + case UNTYPED -> { + int statistic = player.getStatistic(s.statistic()); if (removeAmount >= statistic) { removeAmount -= statistic; - player.setStatistic(s.statistic(), s.material(), 0); + player.setStatistic(s.statistic(), 0); } else { - player.setStatistic(s.statistic(), s.material(), - statistic - removeAmount); + player.setStatistic(s.statistic(), statistic - removeAmount); removeAmount = 0; } } - } - case ENTITY -> { - if (s.entity() == null) - { - // Just a sanity check. Entity cannot be null at this point of code. - removeAmount = 0; - } - else - { - int statistic = player.getStatistic(s.statistic(), s.entity()); + case ITEM, BLOCK -> { + if (s.material() == null) { + // Just a sanity check. Entity cannot be null at this point of code. + removeAmount = 0; + } else { + int statistic = player.getStatistic(s.statistic(), s.material()); - if (removeAmount >= statistic) + if (removeAmount >= statistic) { + removeAmount -= statistic; + player.setStatistic(s.statistic(), s.material(), 0); + } else { + player.setStatistic(s.statistic(), s.material(), statistic - removeAmount); + removeAmount = 0; + } + } + } + case ENTITY -> { + if (s.entity() == null) { - removeAmount -= statistic; - player.setStatistic(s.statistic(), s.entity(), 0); + // Just a sanity check. Entity cannot be null at this point of code. + removeAmount = 0; } else { - player.setStatistic(s.statistic(), s.entity(), - statistic - removeAmount); - removeAmount = 0; + int statistic = player.getStatistic(s.statistic(), s.entity()); + + if (removeAmount >= statistic) { + removeAmount -= statistic; + player.setStatistic(s.statistic(), s.entity(), 0); + } else { + player.setStatistic(s.statistic(), s.entity(), statistic - removeAmount); + removeAmount = 0; + } } } - } + } } } } - } } } } @@ -1426,6 +1416,15 @@ public class TryToComplete Utils.sendMessage(this.user, this.world, Constants.ERRORS + "island-level", TextVariables.NUMBER, String.valueOf(requirements.getRequiredIslandLevel())); + } else if (this.addon.getPlugin().getHooks().getHook("PlaceholderAPI").isPresent() + && !requirements.getPapiString().isEmpty() + && !CheckPapi.evaluate(user.getPlayer(), requirements.getPapiString())) { + Utils.sendMessage(this.user, this.world, Constants.ERRORS + "incorrect"); + if (!requirements.getPapiString().isEmpty()) { + addon.log("FYI:.Challenge failed for " + user.getName() + ". PAPI formula: " + + requirements.getPapiString() + " = " + + CheckPapi.evaluate(user.getPlayer(), requirements.getPapiString())); + } } else { @@ -1451,7 +1450,6 @@ public class TryToComplete // Section: Statistic Challenge // --------------------------------------------------------------------- - /** * Checks if a statistic challenge can be completed or not * It returns ChallengeResult. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 7d88377..88eb994 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -415,6 +415,13 @@ challenges: &7 money on the player's &7 account for the challenge. value: "&7 Current value: &e [number]" + required_papi: + name: "&f&l Required PAPI" + description: |- + &7 Checks a formula that can + &7 include PAPI placeholders + &7 and math and logical elements. + value: "&7 Formula: &e [formula]" statistic: name: "&f&l Statistic" description: |- @@ -1130,6 +1137,7 @@ challenges: file-name-exist: "&c A file named '[id]' already exists. Cannot overwrite." write-search: "&e Please enter a search value. (Type 'cancel' to exit)" search-updated: "&a Search value updated." + enter-formula: "&a Enter a formula that uses PAPI number placeholders and symbols =,<>,<+,>=, AND, OR only.\n&a Example: &7 %aoneblock_my_island_lifetime_count% >= 1000 AND %Level_aoneblock_island_level% >= 100" titles: challenge-title: "Success!" challenge-subtitle: "[friendlyName]"