Merge pull request #378 from BentoBoxWorld/PAPI_strings

Add string comparison for PAPI placeholders #366
This commit is contained in:
tastybento 2025-02-15 08:21:10 +09:00 committed by GitHub
commit 362d846816
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 286 additions and 80 deletions

View File

@ -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.
* <p>
* The formula may contain conditions comparing numeric or string values.
* Operands may contain spaces. The grammar for a condition is:
* <pre>
* leftOperand operator rightOperand
* </pre>
* 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.
* <p>
* Supported comparison operators (case sensitive) are:
* <ul>
* <li>"=" or "==" for equality</li>
* <li>"<>" or "!=" for inequality</li>
* <li>"<=" and ">=" for less than or equal and greater than or equal</li>
* <li>"<" and ">" for less than and greater than</li>
* </ul>
*
* For strings:
* <ul>
* <li>"=" for case insensitive equality</li>
* <li>"==" for case-sensitive equality</li>
* <li>"<>" for case-insensitive inequality</li>
* <li>"!=" for case sensitive inequality</li>
* </ul>
* Boolean connectors "AND" and "OR" (case insensitive) combine multiple conditions;
* AND has higher precedence than OR.
* <p>
* Examples:
* <pre>
* "%aoneblock_my_island_lifetime_count% >= 1000 AND %aoneblock_my_island_level% >= 100"
* "john smith == tasty bento AND 40 > 20"
* </pre>
*
* 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<String> 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:
*
* <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 >.
* A simple recursive descent parser that evaluates the formula.
* It supports multi-token operands for conditions.
*/
private static class Parser {
private final List<String> 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
* <p>
* 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;
}
}
}

View File

@ -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<String>) 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 >"));
}
}