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 index 5b6b738..20f5b15 100644 --- a/src/main/java/world/bentobox/challenges/database/object/requirements/CheckPapi.java +++ b/src/main/java/world/bentobox/challenges/database/object/requirements/CheckPapi.java @@ -7,28 +7,56 @@ import java.util.List; import org.bukkit.entity.Player; import me.clip.placeholderapi.PlaceholderAPI; +import world.bentobox.bentobox.BentoBox; 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. + * Evaluates the given formula by first replacing PAPI placeholders using the provided Player, + * then parsing and evaluating one or more conditions. + *

+ * The formula may contain conditions comparing numeric or string values. + * Operands may contain spaces. The grammar for a condition is: + *

+     *     leftOperand operator rightOperand
+     * 
+ * where the leftOperand is a sequence of tokens (separated by whitespace) until a valid + * operator is found, and the rightOperand is a sequence of tokens until a boolean operator + * ("AND" or "OR") is encountered or the end of the formula is reached. + *

+ * Supported comparison operators (case sensitive) are: + *

+ * + * For strings: + * + * Boolean connectors "AND" and "OR" (case insensitive) combine multiple conditions; + * AND has higher precedence than OR. + *

+ * Examples: + *

+     *     "%aoneblock_my_island_lifetime_count% >= 1000 AND %aoneblock_my_island_level% >= 100"
+     *     "john smith == tasty bento AND 40 > 20"
+     * 
* - * 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. + * @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. + // Replace PAPI placeholders with actual values. String parsedFormula = PlaceholderAPI.setPlaceholders(player, formula); - // Tokenize the parsed formula (tokens are assumed to be separated by whitespace). + // Tokenize the resulting formula by whitespace. List tokens = tokenize(parsedFormula); if (tokens.isEmpty()) { return false; @@ -37,19 +65,19 @@ public class CheckPapi { try { Parser parser = new Parser(tokens); boolean result = parser.parseExpression(); - // If there are leftover tokens, the expression is malformed. + // If there are extra tokens after parsing the full expression, the formula is malformed. if (parser.hasNext()) { return false; } return result; } catch (Exception e) { - // Any error in parsing or evaluation results in false. + // Any error in parsing or evaluating the expression results in false. return false; } } /** - * Splits the given string into tokens using whitespace as the delimiter. + * Splits a string into tokens using whitespace as the delimiter. * * @param s the string to tokenize. * @return a list of tokens. @@ -59,17 +87,8 @@ public class CheckPapi { } /** - * 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 >. + * A simple recursive descent parser that evaluates the formula. + * It supports multi-token operands for conditions. */ private static class Parser { private final List tokens; @@ -79,34 +98,27 @@ public class CheckPapi { 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 } + * Expression -> Term { OR Term } + * + * @return the boolean value of the expression. */ public boolean parseExpression() { boolean value = parseTerm(); - while (hasNext() && peek().equalsIgnoreCase("OR")) { + while (hasNext() && isOr(peek())) { next(); // consume "OR" boolean termValue = parseTerm(); value = value || termValue; @@ -116,67 +128,159 @@ public class CheckPapi { /** * Parses a Term: - * Term -> Factor { AND Factor } + * Term -> Condition { AND Condition } + * + * @return the boolean value of the term. */ public boolean parseTerm() { - boolean value = parseFactor(); - while (hasNext() && peek().equalsIgnoreCase("AND")) { + boolean value = parseCondition(); + while (hasNext() && isAnd(peek())) { next(); // consume "AND" - boolean factorValue = parseFactor(); - value = value && factorValue; + boolean conditionValue = parseCondition(); + value = value && conditionValue; } return value; } /** - * Parses a Factor, which is a single condition in the form: - * operand operator operand - * - * For example: "1234 >= 1000" + * Parses a single condition of the form: + * leftOperand operator rightOperand + *

+ * The left operand is built by collecting tokens until a valid operator is found. + * The right operand is built by collecting tokens until a boolean operator ("AND" or "OR") + * is encountered or the end of the token list is reached. * * @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"); + public boolean parseCondition() { + // Parse left operand. + StringBuilder leftSB = new StringBuilder(); + if (!hasNext()) { + BentoBox.getInstance() + .logError("Challenges PAPI formula error: Expected left operand but reached end of expression"); + return false; } - - String leftOperand = next(); + // Collect tokens for the left operand until an operator is encountered. + while (hasNext() && !isOperator(peek())) { + if (leftSB.length() > 0) { + leftSB.append(" "); + } + leftSB.append(next()); + } + if (!hasNext()) { + throw new RuntimeException("Operator expected after left operand"); + } + // Next token should be an operator. String operator = next(); - String rightOperand = next(); - - // Validate operator. - if (!operator.equals("=") && !operator.equals("<>") && !operator.equals("<=") && !operator.equals(">=") - && !operator.equals("<") && !operator.equals(">")) { + if (!isValidOperator(operator)) { throw new RuntimeException("Invalid operator: " + operator); } + // Parse right operand. + StringBuilder rightSB = new StringBuilder(); + while (hasNext() && !isBooleanOperator(peek())) { + if (rightSB.length() > 0) { + rightSB.append(" "); + } + rightSB.append(next()); + } + String leftOperand = leftSB.toString().trim(); + String rightOperand = rightSB.toString().trim(); - double leftVal, rightVal; - try { - leftVal = Double.parseDouble(leftOperand); - rightVal = Double.parseDouble(rightOperand); - } catch (NumberFormatException e) { - // If either operand is not numeric, return false. + if (rightOperand.isEmpty()) { 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; + + // Evaluate the condition: + // If both operands can be parsed as numbers, use numeric comparison; + // otherwise, perform string comparison. + Double leftNum = tryParseDouble(leftOperand); + Double rightNum = tryParseDouble(rightOperand); + if (leftNum != null && rightNum != null) { + // Numeric comparison. + switch (operator) { + case "=": + case "==": + return Double.compare(leftNum, rightNum) == 0; + case "<>": + case "!=": + return Double.compare(leftNum, rightNum) != 0; + case "<=": + return leftNum <= rightNum; + case ">=": + return leftNum >= rightNum; + case "<": + return leftNum < rightNum; + case ">": + return leftNum > rightNum; + default: + BentoBox.getInstance().logError("Challenges PAPI formula error: Unsupported operator: " + operator); + return false; + } + } else { + // String comparison. + switch (operator) { + case "=": + return leftOperand.equalsIgnoreCase(rightOperand); + case "==": + return leftOperand.equals(rightOperand); + case "<>": + return !leftOperand.equalsIgnoreCase(rightOperand); + case "!=": + return !leftOperand.equals(rightOperand); + case "<=": + return leftOperand.compareTo(rightOperand) <= 0; + case ">=": + return leftOperand.compareTo(rightOperand) >= 0; + case "<": + return leftOperand.compareTo(rightOperand) < 0; + case ">": + return leftOperand.compareTo(rightOperand) > 0; + default: + BentoBox.getInstance().logError("Challenges PAPI formula error: Unsupported operator: " + operator); + return false; + } + } + } + + /** + * Checks if the given token is one of the valid comparison operators. + */ + private boolean isValidOperator(String token) { + return token.equals("=") || token.equals("==") || token.equals("<>") || token.equals("!=") + || token.equals("<=") || token.equals(">=") || token.equals("<") || token.equals(">"); + } + + /** + * Returns true if the token is a comparison operator. + */ + private boolean isOperator(String token) { + return isValidOperator(token); + } + + /** + * Returns true if the token is a boolean operator ("AND" or "OR"). + */ + private boolean isBooleanOperator(String token) { + return token.equalsIgnoreCase("AND") || token.equalsIgnoreCase("OR"); + } + + private boolean isAnd(String token) { + return token.equalsIgnoreCase("AND"); + } + + private boolean isOr(String token) { + return token.equalsIgnoreCase("OR"); + } + + /** + * Tries to parse the given string as a Double. + * Returns the Double if successful, or null if parsing fails. + */ + private Double tryParseDouble(String s) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; } } } diff --git a/src/test/java/world/bentobox/challenges/database/object/requirements/CheckPapiTest.java b/src/test/java/world/bentobox/challenges/database/object/requirements/CheckPapiTest.java new file mode 100644 index 0000000..b001391 --- /dev/null +++ b/src/test/java/world/bentobox/challenges/database/object/requirements/CheckPapiTest.java @@ -0,0 +1,102 @@ +package world.bentobox.challenges.database.object.requirements; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import org.bukkit.entity.Player; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import me.clip.placeholderapi.PlaceholderAPI; + +@RunWith(PowerMockRunner.class) +@PrepareForTest(PlaceholderAPI.class) +public class CheckPapiTest { + + @Mock + private Player player; + + @Before + public void setUp() { + PowerMockito.mockStatic(PlaceholderAPI.class, Mockito.RETURNS_MOCKS); + // Return back the input string + when(PlaceholderAPI.setPlaceholders(eq(player), anyString())) + .thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + } + + @Test + public void testNumericEquality() { + // Using numeric equality comparisons. + assertTrue(CheckPapi.evaluate(player, "40 == 40")); + assertFalse(CheckPapi.evaluate(player, "40 == 50")); + assertTrue(CheckPapi.evaluate(player, "100 = 100")); + assertFalse(CheckPapi.evaluate(player, "100 = 101")); + } + + @Test + public void testNumericComparison() { + assertTrue(CheckPapi.evaluate(player, "40 > 20")); + assertFalse(CheckPapi.evaluate(player, "20 > 40")); + assertTrue(CheckPapi.evaluate(player, "20 < 40")); + assertFalse(CheckPapi.evaluate(player, "40 < 20")); + assertTrue(CheckPapi.evaluate(player, "30 <= 30")); + assertFalse(CheckPapi.evaluate(player, "31 <= 30")); + assertTrue(CheckPapi.evaluate(player, "30 >= 30")); + assertFalse(CheckPapi.evaluate(player, "29 >= 30")); + // Extra tokens beyond a valid expression. + assertTrue(CheckPapi.evaluate(player, "40 > 20 extra")); + + } + + @Test + public void testStringEquality() { + // String comparisons with multi-word operands. + assertTrue(CheckPapi.evaluate(player, "john smith == john smith")); + assertFalse(CheckPapi.evaluate(player, "john smith == jane doe")); + // Using inequality operators. + assertTrue(CheckPapi.evaluate(player, "john smith <> jane doe")); + assertFalse(CheckPapi.evaluate(player, "john smith <> john smith")); + } + + @Test + public void testStringLexicographicalComparison() { + // Lexicographical comparison using string compareTo semantics. + assertTrue(CheckPapi.evaluate(player, "apple < banana")); + assertTrue(CheckPapi.evaluate(player, "banana > apple")); + assertTrue(CheckPapi.evaluate(player, "cat >= cat")); + assertTrue(CheckPapi.evaluate(player, "cat <= cat")); + } + + @Test + public void testMultipleConditionsAndOr() { + // AND has higher precedence than OR. + // "john smith == john smith AND 40 > 20" should be true. + assertTrue(CheckPapi.evaluate(player, "john smith == john smith AND 40 > 20")); + // "john smith == jane doe OR 40 > 20" should be true because second condition is true. + assertTrue(CheckPapi.evaluate(player, "john smith == jane doe OR 40 > 20")); + // "john smith == jane doe AND 40 > 20" should be false because first condition fails. + assertFalse(CheckPapi.evaluate(player, "john smith == jane doe AND 40 > 20")); + // Mixed AND and OR: AND is evaluated first. + // Equivalent to: (john smith == jane doe) OR ((40 > 20) AND (10 < 20)) + assertTrue(CheckPapi.evaluate(player, "john smith == jane doe OR 40 > 20 AND 10 < 20")); + } + + @Test + public void testInvalidFormula() { + // Missing operator between operands. + assertFalse(CheckPapi.evaluate(player, "40 40")); + // Incomplete condition. + assertFalse(CheckPapi.evaluate(player, "40 >")); + } + +}