Merge pull request #376 from BentoBoxWorld/papi

Added a PAPI formula option to Other challenge types.
This commit is contained in:
tastybento 2025-02-12 21:24:09 +09:00 committed by GitHub
commit 39addcabf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 314 additions and 62 deletions

12
pom.xml
View File

@ -117,6 +117,11 @@
<id>minecraft-repo</id>
<url>https://libraries.minecraft.net/</url>
</repository>
<!-- Placeholder API -->
<repository>
<id>placeholderapi</id>
<url>https://repo.extendedclip.com/releases/</url>
</repository>
</repositories>
<dependencies>
@ -198,6 +203,13 @@
<artifactId>commons-math3</artifactId>
<version>3.6.1</version>
</dependency>
<!-- Placeholder API -->
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>2.11.6</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>

View File

@ -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<String> 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<String> tokenize(String s) {
return new ArrayList<>(Arrays.asList(s.split("\\s+")));
}
/**
* A simple recursive descent parser that evaluates expressions according to the following grammar:
*
* <pre>
* Expression -> Term { OR Term }
* Term -> Factor { AND Factor }
* Factor -> operand operator operand
* </pre>
*
* 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<String> tokens;
private int pos = 0;
public Parser(List<String> 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;
}
}
}
}

View File

@ -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;
}

View File

@ -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<String> 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,
}
// ---------------------------------------------------------------------

View File

@ -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<UUID> 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<UUID> 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.

View File

@ -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]"